CUBE SUGAR CONTAINER

技術系のこと書きます。

Python: Adversarial Validation について

最近、Kaggle などのデータ分析コンペで使われることの多い Adversarial Validation という手法について調べたり考えていたので書いてみる。

もくじ

背景

Adversarial Validation という手法は、データ分析コンペに存在する、ある課題を解決するために考案された。 その課題とは、提供される複数のデータセットの分布が異なる場合に、いかにして正しく予測するかというもの。

まず、データ分析コンペでは、一般に学習用データと検証用データが提供される。 学習用データには真のラベル情報が与えられるのに対して、検証用データにはそれがない。 参加者は学習用データでトレーニングしたモデルを用いて、検証用データを予測した結果をサブミットする。

サブミットした内容に対するフィードバックは、検証用データの一部を用いて評価した結果として得られる。 検証用データの一部を用いた評価は、Kaggle では Public Leader Board (Public LB) と呼ばれる。 ただし、これはあくまで一部を用いた評価に過ぎないため、検証用データの残りの部分を使ったときにどうなるかは分からない。 検証用データの残りの部分を使った評価は、コンペが終了したときに Private Leader Board (Private LB) として公表される。

Public LB にもとづいてモデルの良し悪しを評価すると、ときとして Private LB で大きく順位が下がることがある。 この現象は、Public LB の評価で用いられる一部のデータに対して、モデルが過剰適合した状態と考えられる。 これを防ぐには、Cross Validation (交差検証) などを用いたローカルでの汎化性能の評価 (Local CV) が重要になる。

しかし、コンペで提供される学習用データと検証用データは、ときとして分布が大きく異なる。 このとき何が起こるかというと、Local CV と Public LB / Private LB のスコアが相関しなくなる。 例えば Local CV では高いスコアが得られるのに、サブミットした結果では低いスコアしか得られない、といった事態が起こる。

Adversarial Validation

この問題を解決するために考案されたのが Adversarial Validation という手法。 Adversarial Validation では、まず二つのデータセットに本来の目的変数とは別の目的変数を設定する。 具体的には、どちらのデータセットに由来するデータなのかという情報を目的変数として付与する。 例えば学習用データが全て 0 で検証用データは全て 1 といった形で付与する。

そして、学習用データと検証用データを混ぜた状態で、両者がどちらに由来するものなのかを予測するモデルを作る。 学習用データと検証用データの分布が異なるほど、どちらに由来するかの予測は容易になる。 もし、全く同じ分布から得られたデータなのであれば、両者を区別することはそもそもできない。 この時点で、学習用データと検証用データの分布がどれくらい異なるのかが分かる。

もし分布が異なるのであれば、学習用データのサブセットを作ることになる。 サブセットを作る上での基準は、検証用データにより近いデータかどうか。 これは、先ほど確認した内容にもとづいて作成する。 つまり、学習用データの中から、検証用データと判断が難しかったものを選び出す。

具体的には、検証用データかどうかの確率を降順ソートして上から取り出せば良い。 その基準で取り出した学習用データのサブセットを使って Local CV することで、Public LB / Private LB との相関が改善する。 以上が Adversarial Validation という手法の説明になる。

試してみる

前置きがだいぶ長くなったけど、ここからは実際に Adversarial Validation を試してみよう。

使った環境は次の通り。

$ sw_vers
ProductName:    Mac OS X
ProductVersion: 10.14.3
BuildVersion:   18D109
$ python -V
Python 3.7.2

下準備

まずは、検証に使うパッケージをインストールする。

$ pip install scikit-learn matplotlib

二つのデータが同じ分布に由来するとき

まずは、二つのデータが全く同じ分布に由来するとき Adversarial Validation で何が起こるか確認する。

以下のサンプルコードでは二値分類問題に使うダミーのデータを生成している。 総数 5,000 点の二次元データで、割合は 1:1 の均衡データとなっている。 そのデータを 2,500 点ずつランダムにサンプリングして、これを学習用データと検証用データに模している。 これらのデータに、どちらに由来する要素なのかを表す目的変数 z_* を用意してランダムフォレストで二値分類として解いている。 均衡データなので、評価指標には単純に精度 (Accuracy) を用いた。

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

import numpy as np
from sklearn.datasets import make_classification
from sklearn.model_selection import train_test_split
from sklearn.model_selection import cross_validate
from sklearn.model_selection import StratifiedKFold
from matplotlib import pyplot as plt
from sklearn.ensemble import RandomForestClassifier


def main():
    # データ点数 5,000 のデータセットを作るパラメータ
    args = {
        'n_samples': 5000,
        'n_features': 2,
        'n_informative': 2,
        'n_redundant': 0,
        'n_repeated': 0,
        'n_classes': 2,
        'n_clusters_per_class': 1,
        'flip_y': 0,
        'class_sep': 1.5,
        'weights': [0.5, 0.5],
        'random_state': 42,
    }
    # 二次元のデータを生成する
    X, y = make_classification(**args)

    # データをランダムに二等分する
    X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.5,
                                                        shuffle=True, random_state=42)

    # 分割したデータがどちらに由来したものか区別するためのラベル
    z_train = np.zeros(len(X_train))  # 片方は全て 0
    z_test = np.ones(len(X_test))  # もう片方は全て 1

    # 分割したデータを結合する
    X_concat = np.concatenate([X_train, X_test], axis=0)
    z_concat = np.concatenate([z_train, z_test], axis=0)

    # 上記データをランダムフォレストで分類を試みる
    clf = RandomForestClassifier(n_estimators=100,
                                 random_state=42)

    # 元々同じ分布から作ったので、どちら由来か分類するのは困難
    skf = StratifiedKFold(n_splits=5, shuffle=True, random_state=42)
    score = cross_validate(clf, X_concat, z_concat, cv=skf)
    # 5-Fold CV で評価した精度 (Accuracy) の平均
    print('Accuracy:', score['test_score'].mean())

    # 両データをプロットする
    plt.scatter(X_train[y_train == 0, 0],
                X_train[y_train == 0, 1],
                alpha=0.5,
                label='Train (Negative)')
    plt.scatter(X_train[y_train == 1, 0],
                X_train[y_train == 1, 1],
                alpha=0.5,
                label='Train (Positive)')
    plt.scatter(X_test[y_test == 0, 0],
                X_test[y_test == 0, 1],
                alpha=0.5,
                label='Test (Negative)')
    plt.scatter(X_test[y_test == 1, 0],
                X_test[y_test == 1, 1],
                alpha=0.5,
                label='Test (Positive)')
    plt.legend()
    plt.show()


if __name__ == '__main__':
    main()

上記の実行結果は次の通り。 精度 (Accuracy) は 0.5 を割っており、ランダムに答えたときの期待値よりも低い。

$ python samedist.py
Accuracy: 0.47759999999999997

二つのデータをプロットした散布図は次の通り。

f:id:momijiame:20190223142636p:plain

このように、全く同一の分布から得られたものであればデータの由来を予測することは困難といえる。

二つのデータが異なる分布に由来するとき

続いては二つのデータが異なる分布に由来するときに Adversarial Validation で何が起こるかを確認する。

次のサンプルコードでは、乱数を変えて 2,500 点のデータを二つ作っている。 二つのデータはそれなりに似ているものの結構違う、という絶妙なものを探してみた。 先ほどと同じようにどちらのデータに由来するものか目的変数を付与して、それをランダムフォレストで分類している。

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

import numpy as np
from sklearn.datasets import make_classification
from sklearn.model_selection import cross_validate
from sklearn.model_selection import StratifiedKFold
from matplotlib import pyplot as plt
from sklearn.ensemble import RandomForestClassifier


def main():
    # データ点数 2,500 のデータセットを作るパラメータ
    args = {
        'n_samples': 2500,
        'n_features': 2,
        'n_informative': 2,
        'n_redundant': 0,
        'n_repeated': 0,
        'n_classes': 2,
        'n_clusters_per_class': 1,
        'flip_y': 0,
        'class_sep': 1.5,
        'weights': [0.5, 0.5],
        'random_state': 42,
    }
    # 二次元のデータを生成する
    X_train, y_train = make_classification(**args)
    # 乱数を変えてもう一度作る
    args['random_state'] = 424242
    X_test, y_test = make_classification(**args)

    # 分割したデータがどちらに由来したものか区別するためのラベル
    z_train, z_test = np.zeros(len(X_train)), np.ones(len(X_test))

    # 結合する
    X = np.concatenate([X_train, X_test], axis=0)
    z = np.concatenate([z_train, z_test], axis=0)

    # 上記データをランダムフォレストで分類を試みる
    clf = RandomForestClassifier(n_estimators=100,
                                 random_state=42)

    # 異なる乱数を元にした分布なのでそれなりに分類できる
    skf = StratifiedKFold(n_splits=5, shuffle=True, random_state=42)
    score = cross_validate(clf, X, z, cv=skf)
    print('Accuracy:', score['test_score'].mean())

    # 両データをプロットする
    plt.scatter(X_train[y_train == 0, 0],
                X_train[y_train == 0, 1],
                alpha=0.5,
                label='Train (Negative)')
    plt.scatter(X_train[y_train == 1, 0],
                X_train[y_train == 1, 1],
                alpha=0.5,
                label='Train (Positive)')
    plt.scatter(X_test[y_test == 0, 0],
                X_test[y_test == 0, 1],
                alpha=0.5,
                label='Test (Negative)')
    plt.scatter(X_test[y_test == 1, 0],
                X_test[y_test == 1, 1],
                alpha=0.5,
                label='Test (Positive)')
    plt.legend()
    plt.show()


if __name__ == '__main__':
    main()

上記の実行結果は次の通り。

$ python diffdist.py 
Accuracy: 0.8498000000000001

今度は、それなりに分類できている。

データをプロットした散布図は次の通り。 Positive なデータの分布が、特に異なることが分かる。

f:id:momijiame:20190223143315p:plain

このように、分布が異なる場合には Adversarial Validation で、それを確認できる。

異なる分布でそのまま分類してみる

続いて、先ほど確認した分布の異なるデータを使って学習・予測してみよう。 これは Adversarial Validation を使わないアプローチで結果をサブミットしたときの Private LB のスコアに相当する。

次のサンプルコードでは、先ほど生成したのと同じデータセットを用意した。 そして、学習用データでランダムフォレストをトレーニングしてから、検証用データで予測した場合のスコアを確認している。

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

import numpy as np
from matplotlib import pyplot as plt
from sklearn.datasets import make_classification
from sklearn.metrics import accuracy_score
from sklearn.ensemble import RandomForestClassifier


def main():
    args = {
        'n_samples': 2500,
        'n_features': 2,
        'n_informative': 2,
        'n_redundant': 0,
        'n_repeated': 0,
        'n_classes': 2,
        'n_clusters_per_class': 1,
        'flip_y': 0,
        'class_sep': 1.5,
        'weights': [0.5, 0.5],
        'random_state': 42,
    }
    X_train, y_train = make_classification(**args)

    args['random_state'] = 424242
    X_test, y_test = make_classification(**args)

    clf = RandomForestClassifier(n_estimators=100,
                                 random_state=42)

    # 最初に作ったデータセットで学習する
    clf.fit(X_train, y_train)

    # 次に作ったデータセット (分布が異なる) でモデルを評価する
    y_pred = clf.predict(X_test)
    acc = accuracy_score(y_test, y_pred)
    print('Accuracy:', acc)

    # モデルの識別境界を可視化する
    x_mesh, y_mesh = np.meshgrid(np.linspace(-5, 5, 50),
                                 np.linspace(-5, 5, 50))
    Z = clf.predict(np.c_[x_mesh.ravel(),
                          y_mesh.ravel()])
    Z = Z.reshape(x_mesh.shape)
    plt.contourf(x_mesh, y_mesh, Z, cmap=plt.cm.binary)

    # 元のデータをプロットする
    plt.scatter(X_train[y_train == 0, 0],
                X_train[y_train == 0, 1],
                alpha=0.5,
                label='Train (Negative)')
    plt.scatter(X_train[y_train == 1, 0],
                X_train[y_train == 1, 1],
                alpha=0.5,
                label='Train (Positive)')
    plt.scatter(X_test[y_test == 0, 0],
                X_test[y_test == 0, 1],
                alpha=0.5,
                label='Test (Negative)')
    plt.scatter(X_test[y_test == 1, 0],
                X_test[y_test == 1, 1],
                alpha=0.5,
                label='Test (Positive)')
    plt.legend()
    plt.show()

if __name__ == '__main__':
    main()

上記の実行結果は次の通り。 精度 (Accuracy) として 0.9516 という結果が得られた。

$ python nochange.py 
Accuracy: 0.9516

モデルの識別境界をプロットしたグラフが次の通り。

f:id:momijiame:20190223144159p:plain

Test (Positive) のデータの一部が誤って分類されていることが分かる。 とはいえモデルは Test に関する情報を知らないので妥当な結果といえる。

検証用データに似ているものを取り出す

続いては、Adversarial Validation を使って検証用データに似ているものを学習用データから取り出してみよう。

次のサンプルコードでは、Adversarial Validation で検証用データに近いと判断された学習用データの上位 1,000 件を取り出している。 ただし、取り出したデータを使った学習まではしておらずグラフへのプロットにとどまる。

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

import numpy as np
from sklearn.datasets import make_classification
from sklearn.model_selection import cross_val_predict
from matplotlib import pyplot as plt
from sklearn.ensemble import RandomForestClassifier


def main():
    args = {
        'n_samples': 2500,
        'n_features': 2,
        'n_informative': 2,
        'n_redundant': 0,
        'n_repeated': 0,
        'n_classes': 2,
        'n_clusters_per_class': 1,
        'flip_y': 0,
        'class_sep': 1.5,
        'weights': [0.5, 0.5],
        'random_state': 42,
    }
    X_train, y_train = make_classification(**args)

    args['random_state'] = 424242
    X_test, y_test = make_classification(**args)

    # 分割したデータがどちらに由来したものか区別するためのラベル
    z_train, z_test = np.zeros(len(X_train)), np.ones(len(X_test))

    X = np.concatenate([X_train, X_test], axis=0)
    z = np.concatenate([z_train, z_test], axis=0)

    # 要素が最初のデータセット由来なのか、次のデータセット由来なのか分類する
    clf = RandomForestClassifier(n_estimators=100,
                                 random_state=42)

    # 分類して確率を計算する
    z_pred_proba = cross_val_predict(clf, X, z,
                                     cv=5, method='predict_proba')

    # 先に生成したデータに対応する部分だけ取り出す
    z_train_pred_proba = z_pred_proba[:2500]
    # 後から作ったデータに近いと判断されたものを取り出す
    X_train_alike = X_train[np.argsort(z_train_pred_proba[:, 0])][:1000]
    # 上記の残り
    X_train_rest = X_train[np.argsort(z_train_pred_proba[:, 0])][1000:]

    # それぞれのデータをプロットする
    plt.scatter(X_test[:, 0],
                X_test[:, 1],
                alpha=0.5,
                label='Test')
    plt.scatter(X_train_rest[:, 0],
                X_train_rest[:, 1],
                alpha=0.5,
                label='NOT Resemblance')
    plt.scatter(X_train_alike[:, 0],
                X_train_alike[:, 1],
                alpha=0.5,
                label='Resemblance')

    plt.grid()
    plt.legend()
    plt.show()


if __name__ == '__main__':
    main()

上記を実行してみる。

$ python resemblance.py

得られるグラフは次の通り。

f:id:momijiame:20190223162721p:plain

Negative なデータにおいては中心部分が、Positive なデータにおいてはエッジ部分が似ていると判断されたようだ。

取り出したデータを使って学習してみる

続いては、先ほど取り出したデータを使って実際に学習してみよう。

以下のサンプルコードでは、先ほど取り出したのと同じデータを使ってモデルを学習している。 つまり、検証用データに近いと判断された学習用データの要素 1,000 点を使った。 そして、学習させたモデルを使って検証用データのスコアを計算している。 また、同時にモデルの識別境界も可視化した。

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

import numpy as np
from matplotlib import pyplot as plt
from sklearn.datasets import make_classification
from sklearn.metrics import accuracy_score
from sklearn.model_selection import cross_val_predict
from sklearn.ensemble import RandomForestClassifier


def main():
    args = {
        'n_samples': 2500,
        'n_features': 2,
        'n_informative': 2,
        'n_redundant': 0,
        'n_repeated': 0,
        'n_classes': 2,
        'n_clusters_per_class': 1,
        'flip_y': 0,
        'class_sep': 1.5,
        'weights': [0.5, 0.5],
        'random_state': 42,
    }
    X_train, y_train = make_classification(**args)

    args['random_state'] = 424242
    X_test, y_test = make_classification(**args)

    z_train, z_test = np.zeros(len(X_train)), np.ones(len(X_test))

    X = np.concatenate([X_train, X_test], axis=0)
    z = np.concatenate([z_train, z_test], axis=0)

    # 要素が最初のデータセット由来なのか、次のデータセット由来なのか分類する
    clf = RandomForestClassifier(n_estimators=100,
                                 random_state=42)
    z_pred_proba = cross_val_predict(clf, X, z,
                                     cv=5, method='predict_proba')

    # 先に生成したデータに対応する部分だけ取り出す
    z_train_pred_proba = z_pred_proba[:2500]
    # 後から作ったデータに近いと判断されたものを取り出す
    X_train_alike = X_train[np.argsort(z_train_pred_proba[:, 0])][:1000]
    y_train_alike = y_train[np.argsort(z_train_pred_proba[:, 0])][:1000]

    # 似ているデータで学習する
    clf.fit(X_train_alike, y_train_alike)
    # 後から作ったデータで評価する
    y_pred = clf.predict(X_test)
    # 精度 (Accuracy) を確認する
    acc = accuracy_score(y_test, y_pred)
    print('Accuracy:', acc)

    # 識別境界を可視化する
    x_mesh, y_mesh = np.meshgrid(np.linspace(-5, 5, 50),
                                 np.linspace(-5, 5, 50))
    Z = clf.predict(np.c_[x_mesh.ravel(),
                          y_mesh.ravel()])
    Z = Z.reshape(x_mesh.shape)
    plt.contourf(x_mesh, y_mesh, Z, cmap=plt.cm.binary)

    plt.scatter(X_train[y_train == 0, 0],
                X_train[y_train == 0, 1],
                alpha=0.5,
                label='Train (Negative)')
    plt.scatter(X_train[y_train == 1, 0],
                X_train[y_train == 1, 1],
                alpha=0.5,
                label='Train (Positive)')
    plt.scatter(X_test[y_test == 0, 0],
                X_test[y_test == 0, 1],
                alpha=0.5,
                label='Test (Negative)')
    plt.scatter(X_test[y_test == 1, 0],
                X_test[y_test == 1, 1],
                alpha=0.5,
                label='Test (Positive)')
    plt.scatter(X_train_alike[:, 0],
                X_train_alike[:, 1],
                label='Model Learned')
    plt.legend()
    plt.show()


if __name__ == '__main__':
    main()

上記の実行結果は次の通り。 先ほど、学習用データをそのまま使ったパターンよりもスコアが改善している。

$ python advlearn.py 
Accuracy: 0.9788

識別境界を示すグラフは次の通り。

f:id:momijiame:20190223163622p:plain

ラベルごとに似ているデータを取り出す

先ほどの例ではラベルによらず検証用データに似たものを学習用データから 1,000 件取り出した。 ただ、可視化した内容を見ても分かる通り、似ているかどうかはラベルによって異なる場合がある。 例えば、先ほどの例では Negative なラベルに、より多くの似ているデータが含まれていた。 これでは、モデルに学習させるラベルの数が不均衡になる問題点がある。 なので、続いてはラベルごとに似ているデータを取り出してみることにしよう。

次のサンプルコードでは、ラベルごとに似ているデータを 200 件ずつ取り出していて学習に使っている。 取り出したデータの可視化と識別境界をプロットしている点については先ほどと変わらない。

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

import numpy as np
from matplotlib import pyplot as plt
from sklearn.datasets import make_classification
from sklearn.metrics import accuracy_score
from sklearn.model_selection import cross_val_predict
from sklearn.ensemble import RandomForestClassifier


def main():
    args = {
        'n_samples': 2500,
        'n_features': 2,
        'n_informative': 2,
        'n_redundant': 0,
        'n_repeated': 0,
        'n_classes': 2,
        'n_clusters_per_class': 1,
        'flip_y': 0,
        'class_sep': 1.5,
        'weights': [0.5, 0.5],
        'random_state': 42,
    }
    X_train, y_train = make_classification(**args)

    args['random_state'] = 424242
    X_test, y_test = make_classification(**args)

    z_train, z_test = np.zeros(len(X_train)), np.ones(len(X_test))

    X = np.concatenate([X_train, X_test], axis=0)
    z = np.concatenate([z_train, z_test], axis=0)

    # 要素が最初のデータセット由来なのか、次のデータセット由来なのか分類する
    clf = RandomForestClassifier(n_estimators=100,
                                 random_state=42)
    z_pred_proba = cross_val_predict(clf, X, z,
                                     cv=5, method='predict_proba')

    # 先に生成したデータに対応する部分だけ取り出す
    z_train_pred_proba = z_pred_proba[:2500]
    # 後から生成したデータへの近さにもとづいてソートする
    X_train_alike = X_train[np.argsort(z_train_pred_proba[:, 0])]
    y_train_alike = y_train[np.argsort(z_train_pred_proba[:, 0])]
    # 近いものをラベルごとに取り出す
    X_train_alike_neg = X_train_alike[y_train_alike == 0][:100]
    y_train_alike_neg = y_train_alike[y_train_alike == 0][:100]
    X_train_alike_pos = X_train_alike[y_train_alike == 1][:100]
    y_train_alike_pos = y_train_alike[y_train_alike == 1][:100]

    X_train_alike_concat = np.concatenate([X_train_alike_neg,
                                           X_train_alike_pos],
                                          axis=0)
    y_train_alike_concat = np.concatenate([y_train_alike_neg,
                                           y_train_alike_pos],
                                          axis=0)

    # 似ているデータで学習する
    clf.fit(X_train_alike_concat, y_train_alike_concat)
    # 後から作ったデータで評価する
    y_pred = clf.predict(X_test)
    # 精度 (Accuracy) を確認する
    acc = accuracy_score(y_test, y_pred)
    print('Accuracy:', acc)

    # 識別境界を可視化する
    x_mesh, y_mesh = np.meshgrid(np.linspace(-5, 5, 50),
                                 np.linspace(-5, 5, 50))
    Z = clf.predict(np.c_[x_mesh.ravel(),
                          y_mesh.ravel()])
    Z = Z.reshape(x_mesh.shape)
    plt.contourf(x_mesh, y_mesh, Z, cmap=plt.cm.binary)

    plt.scatter(X_train[y_train == 0, 0],
                X_train[y_train == 0, 1],
                alpha=0.5,
                label='Train (Negative)')
    plt.scatter(X_train[y_train == 1, 0],
                X_train[y_train == 1, 1],
                alpha=0.5,
                label='Train (Positive)')
    plt.scatter(X_test[y_test == 0, 0],
                X_test[y_test == 0, 1],
                alpha=0.5,
                label='Test (Negative)')
    plt.scatter(X_test[y_test == 1, 0],
                X_test[y_test == 1, 1],
                alpha=0.5,
                label='Test (Positive)')
    plt.scatter(X_train_alike_concat[:, 0],
                X_train_alike_concat[:, 1],
                label='Model Learned')
    plt.legend()
    plt.show()


if __name__ == '__main__':
    main()

上記の実行結果は次の通り。 先ほどよりもスコアがさらに改善している。

$ python perlabel.py 
Accuracy: 0.9812

取り出したデータと識別境界をプロットしたグラフは次の通り。

f:id:momijiame:20190223164105p:plain

どれだけ取り出して学習させれば良いのか

ここまでで、検証用データに似たデータを学習用データから取り出して使うとスコアが上昇する可能性があることが分かった。 とはいえ、まだ疑問が残っている。 一体、どれだけ取り出して学習させると良い結果が得られるのだろうか。 先に断っておくと、まだ自分の中で結論は出ていない。 先ほど用いた上位 200 件というのは、特に根拠のない適当に選んだ数値だった。

試しに次のサンプルコードでは取り出す件数と精度の推移をプロットしている。 また、そのとき選ばれた末尾のデータは Adversarial Validation でどれくらい「検証用データっぽい」と判断されたのかも可視化した。

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

import numpy as np
from matplotlib import pyplot as plt
from sklearn.datasets import make_classification
from sklearn.metrics import accuracy_score
from sklearn.model_selection import cross_val_predict
from sklearn.ensemble import RandomForestClassifier


def main():
    args = {
        'n_samples': 2500,
        'n_features': 2,
        'n_informative': 2,
        'n_redundant': 0,
        'n_repeated': 0,
        'n_classes': 2,
        'n_clusters_per_class': 1,
        'flip_y': 0,
        'class_sep': 1.5,
        'weights': [0.5, 0.5],
        'random_state': 42,
    }
    X_train, y_train = make_classification(**args)

    args['random_state'] = 424242
    X_test, y_test = make_classification(**args)

    z_train, z_test = np.zeros(len(X_train)), np.ones(len(X_test))

    X = np.concatenate([X_train, X_test], axis=0)
    z = np.concatenate([z_train, z_test], axis=0)

    # 要素が最初のデータセット由来なのか、次のデータセット由来なのか分類する
    clf = RandomForestClassifier(n_estimators=100,
                                 random_state=42)
    z_pred_proba = cross_val_predict(clf, X, z,
                                     cv=5, method='predict_proba')

    # 先に生成したデータに対応する部分だけ取り出す
    z_train_pred_proba = z_pred_proba[:2500]
    # 後から生成したデータへの近さにもとづいてソートする
    sorted_train_index_test_similarity = np.argsort(z_train_pred_proba[:, 0])
    X_train_alike = X_train[sorted_train_index_test_similarity]
    y_train_alike = y_train[sorted_train_index_test_similarity]
    sorted_train_proba_test_similarity = np.sort(z_train_pred_proba[:, 0])

    accuracies = []
    probabilities = []
    picks = list(range(100, 2500, 100))
    for i in picks:
        # 近いものをラベルごとに取り出す
        X_train_alike_neg = X_train_alike[y_train_alike == 0][:i]
        y_train_alike_neg = y_train_alike[y_train_alike == 0][:i]
        X_train_alike_pos = X_train_alike[y_train_alike == 1][:i]
        y_train_alike_pos = y_train_alike[y_train_alike == 1][:i]

        X_train_alike_concat = np.concatenate([X_train_alike_neg,
                                               X_train_alike_pos],
                                              axis=0)
        y_train_alike_concat = np.concatenate([y_train_alike_neg,
                                               y_train_alike_pos],
                                              axis=0)

        # 似ているデータで学習する
        clf.fit(X_train_alike_concat, y_train_alike_concat)
        # 後から作ったデータで評価する
        y_pred = clf.predict(X_test)
        # 精度 (Accuracy) を確認する
        acc = accuracy_score(y_test, y_pred)
        accuracies.append(acc)
        # 取り出したデータの検証用データっぽさ (確率) を確認する
        proba = 1.0 - sorted_train_proba_test_similarity[i]
        probabilities.append(proba)

    # 結果をプロットする
    _, ax1 = plt.subplots(figsize=(8, 4))
    ax1.plot(picks, accuracies, c='b')
    ax1.set_xlabel('picks')
    ax1.set_ylabel('accuracy')

    ax2 = ax1.twinx()
    ax2.plot(picks, probabilities, c='r')
    ax2.set_xlabel('picks')
    ax2.set_ylabel('probability')

    plt.show()


if __name__ == '__main__':
    main()

なお、この精度の推移は Private LB の結果と同じ意味を持っているので、コンペ開催中にこのような探索をすることはできない。 代わりに、Public LB であれば Local CV との相関の推移を比較することはできると思う。

上記を実行してみる。

$ python pickacc.py

得られたグラフは次の通り。

f:id:momijiame:20190223180541p:plain

どうやら、今回のデータでは上位 1,200 件を取り出すあたりから精度が大きく改善していることが分かる。 そして、結局上位 100 ~ 200 件を取り出すのが、最も良い結果が得られていた。 今回のケースでは、学習用データを大きく捨ててでも、より似ているデータを取り出す方が良い結果になるようだ。

ただし、上記は用いるデータによって結果は異なると考えられる。 おそらく Adversarial Validation でどれだけ高いスコアが得られたか、つまり分布が似ているか否かで取り出す量を変化させるのが良い気がしている。

Adversarial Validation の応用例

主にデータ分析コンペで用いられる Adversarial Validation だけど、おそらく現実世界でも応用が効くところはあると考えている。

例えば、運用中の機械学習モデルの再学習をするタイミングの判断に使えたりするんじゃないだろうか。 このアイデアでは、モデルの学習に使ったデータと、リアルタイムのデータを Adversarial Validation する。 モデルの学習に使ったデータとリアルタイムのデータの分布が近ければ、分類はできないはず。 しかし、分布が異なってくれば分類できるようになる。 この場合、運用中の機械学習モデルは未知の分布を処理していることになるので性能が低下している恐れがある。

このように、本来の目的変数とは異なる目的変数をデータに付与して分類させるという Adversarial Validation の考え方は、色々と応用が効きそうな気がしている。

いじょう。

Python: scikit-learn の cross_validate() 関数で独自の評価指標を計算する

今回は scikit-learncross_validate() 関数で、組み込みでは用意されていないような評価指標を計算する方法について書く。

使った環境は次の通り。

$ sw_vers 
ProductName:    Mac OS X
ProductVersion: 10.14.3
BuildVersion:   18D109
$ python -V               
Python 3.7.2

下準備

下準備として、事前に scikit-learn をインストールしておく。

$ pip install scikit-learn

cross_validate() 関数について

まずは cross_validate() 関数のおさらいから。 この関数は、その名の通り機械学習モデルの汎化性能を Cross Validation (交差検証) で評価するためにある。

次のサンプルコードでは、Breast Cancer データセットをランダムフォレストで学習させた場合の汎化性能を Stratified k-Fold CV で評価している。 cross_validate() 関数は、一度に複数の評価指標を計算できる点も特徴的。 以下では、組み込みで用意されている Accuracy, Precision, Recall, F1-Value について計算している。

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

from sklearn import datasets
from sklearn.ensemble import RandomForestClassifier
from sklearn.model_selection import StratifiedKFold
from sklearn.model_selection import cross_validate


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

    # モデルを用意する
    clf = RandomForestClassifier(n_estimators=100, random_state=42)

    # Stratified k-Fold で汎化性能を評価する
    skf = StratifiedKFold(n_splits=5, shuffle=True, random_state=42)
    # 評価する指標
    score_funcs = [
        'accuracy',
        'precision',
        'recall',
        'f1',
    ]
    # Cross Validation で検証する
    scores = cross_validate(clf, X, y, cv=skf, scoring=score_funcs)
    # 得られた指標を出力する
    print('accuracy:', scores['test_accuracy'].mean())
    print('precision:', scores['test_precision'].mean())
    print('recall:', scores['test_recall'].mean())
    print('f1:', scores['test_f1'].mean())


if __name__ == '__main__':
    main()

上記の実行結果は次の通り。

$ python cv.py 
accuracy: 0.9631088880338592
precision: 0.961883227291678
recall: 0.9805164319248826
f1: 0.9708395567654284

独自の評価指標を組み込む

続いて本題の独自の評価指標を組み込む方法について。 独自の評価指標を計算したいときは、真の値と推定した値を引数として受け取る関数を定義する。 そして、関数では引数を元に評価指標を計算して返す。

以下のサンプルコードでは、手始めに精度 (Accuracy) を計算する関数 accuracy_score を定義している。 精度 (Accuracy) の計算は組み込みでも用意されているけど、まずはそれと差異が出ないことを比較できるようにするため。 ポイントとして、定義した関数は make_scorer() 関数でラップする必要がある。

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

from sklearn import datasets
from sklearn.ensemble import RandomForestClassifier
from sklearn.model_selection import StratifiedKFold
from sklearn.model_selection import cross_validate
from sklearn.metrics import make_scorer


def accuracy_score(y_true, y_pred):
    """オリジナルの評価関数 (ただし単なる精度の計算)"""
    accuracy = sum(y_true == y_pred) / len(y_true)
    return accuracy


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

    clf = RandomForestClassifier(n_estimators=100, random_state=42)

    skf = StratifiedKFold(n_splits=5, shuffle=True, random_state=42)

    score_funcs = {
        'builtin_accuracy': 'accuracy',
        # オリジナルの評価関数を登録する
        'custom_accuracy': make_scorer(accuracy_score),  # make_scorer() でラップする
    }

    scores = cross_validate(clf, X, y, cv=skf, scoring=score_funcs)
    print('built-in accuracy:', scores['test_builtin_accuracy'].mean())
    print('custom accuracy:', scores['test_custom_accuracy'].mean())


if __name__ == '__main__':
    main()

上記の実行結果は次の通り。 組み込みの精度 (Accuracy) 計算と同じ値が得られていることが分かる。

$ python acc.py 
built-in accuracy: 0.9631088880338592
custom accuracy: 0.9631088880338592

回帰問題でもやってみる

先ほどは分類問題だったので、念のため次は回帰問題でも確認しておこう。

以下のサンプルコードでは Boston データセットを ElasticNet 回帰で処理している。 独自の評価指標を計算する関数は rmse_score() という関数を用意した。 これは、文字通り RMSE (Root Mean Square Error: 平均二乗誤差平方根) を計算する関数になっている。

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

import math

from sklearn import datasets
from sklearn.linear_model import ElasticNet
from sklearn.model_selection import KFold
from sklearn.model_selection import cross_validate
from sklearn.metrics import mean_squared_error
from sklearn.metrics import make_scorer


def rmse_score(y_true, y_pred):
    """RMSE (Root Mean Square Error: 平均二乗誤差平方根) を計算する関数"""
    mse = mean_squared_error(y_true, y_pred)
    rmse = math.sqrt(mse)
    return rmse


def main():
    # Boston データセットを使った回帰問題
    dataset = datasets.load_boston()
    X, y = dataset.data, dataset.target

    # ElasticNet 回帰
    reg = ElasticNet(random_state=42)

    kf = KFold(n_splits=5, shuffle=True, random_state=42)

    score_funcs = {
        'rmse': make_scorer(rmse_score),
    }

    scores = cross_validate(reg, X, y, cv=kf, scoring=score_funcs)
    mean_rmse = scores['test_rmse'].mean()
    print('RMSE:', mean_rmse)


if __name__ == '__main__':
    main()

上記の実行結果は次の通り。

$ python rmse.py   
RMSE: 5.274746876673534

ちゃんと計算できているようだ。

めでたしめでたし。

機械学習のための特徴量エンジニアリング ―その原理とPythonによる実践

機械学習のための特徴量エンジニアリング ―その原理とPythonによる実践

  • 作者: Alice Zheng,Amanda Casari,株式会社ホクソエム
  • 出版社/メーカー: オライリージャパン
  • 発売日: 2019/02/23
  • メディア: 単行本(ソフトカバー)
  • この商品を含むブログを見る

Python: CatBoost を使ってみる

今回は CatBoost という、機械学習の勾配ブースティング決定木 (Gradient Boosting Decision Tree) というアルゴリズムを扱うためのフレームワークを試してみる。 CatBoost は、同じ勾配ブースティング決定木を扱うフレームワークの LightGBMXGBoost と並んでよく用いられている。

CatBoost は学習にかかる時間が LightGBM や XGBoost に劣るものの、特にカテゴリカル変数を含むデータセットの扱いに定評がある。 ただし、今回使うデータセットはカテゴリカル変数を含まない点について先に断っておく。

使った環境は次の通り。

$ sw_vers                                     
ProductName:    Mac OS X
ProductVersion: 10.14.3
BuildVersion:   18D109
$ python -V            
Python 3.7.2

インストール

まずは pip を使って CatBoost をインストールする。 また、データセットの読み込みなどに使うため一緒に scikit-learn も入れておこう。

$ pip install catboost scikit-learn

基本的な使い方 (二値分類問題)

まずは Breast Cancer データセットを使った二値分類問題を CatBoost で処理してみよう。 以下のサンプルコードでは、CatBoost を学習させた上で汎化性能をホールドアウト検証で確認している。

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

from catboost import CatBoost
from catboost import Pool

from sklearn import datasets
from sklearn.model_selection import train_test_split
from sklearn.metrics import accuracy_score


def main():
    # 乳がんデータセットを読み込む
    dataset = datasets.load_breast_cancer()
    X, y = dataset.data, dataset.target
    # データセットを学習用と検証用に分割する
    X_train, X_test, y_train, y_test = train_test_split(X, y,
                                                        test_size=0.3,
                                                        shuffle=True,
                                                        random_state=42,
                                                        stratify=y)
    # CatBoost が扱うデータセットの形式に直す
    train_pool = Pool(X_train, label=y_train)
    test_pool = Pool(X_test, label=y_test)
    # 学習用のパラメータ
    params = {
        # タスク設定と損失関数
        'loss_function': 'Logloss',
        # 学習ラウンド数
        'num_boost_round': 100,
    }
    # モデルを学習する
    model = CatBoost(params)
    model.fit(train_pool)
    # 検証用データを分類する
    # NOTE: 確率がほしいときは prediction_type='Probability' を使う
    y_pred = model.predict(test_pool, prediction_type='Class')
    # 精度 (Accuracy) を検証する
    acc = accuracy_score(y_test, y_pred)
    print('Accuracy:', acc)


if __name__ == '__main__':
    main()

上記を実行してみよう。 各ラウンドでの学習データに対する損失と時間が表示される。 それぞれのラウンドでは決定木がひとつずつ作られている。

$ python helloworld.py 
Learning rate set to 0.100436
0: learn: 0.5477448   total: 108ms    remaining: 10.6s
1: learn: 0.4414628   total: 140ms    remaining: 6.85s
2: learn: 0.3561365   total: 172ms    remaining: 5.55s
...(snip)...
97:    learn: 0.0120305   total: 3.26s   remaining: 66.6ms
98:    learn: 0.0116963   total: 3.29s   remaining: 33.3ms
99:    learn: 0.0113142   total: 3.33s   remaining: 0us
Accuracy: 0.9532163742690059

最終的に精度 (Accuracy) で約 0.953 という結果が得られた。

ラウンド数を増やしてみる

先ほどの例ではラウンド数、つまり用いる決定木の数が最大で 100 本だった。 次は、試しにこの数を 1,000 まで増やしてみよう。

以下のサンプルコードではラウンド数を増やした上で、最も最適なラウンド数を用いて最終的な性能を確認している。

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

from catboost import CatBoost
from catboost import Pool

from sklearn import datasets
from sklearn.model_selection import train_test_split
from sklearn.metrics import accuracy_score


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

    X_train, X_test, y_train, y_test = train_test_split(X, y,
                                                        test_size=0.3,
                                                        shuffle=True,
                                                        random_state=42,
                                                        stratify=y)

    train_pool = Pool(X_train, label=y_train)
    test_pool = Pool(X_test, label=y_test)

    params = {
        'loss_function': 'Logloss',
        'num_boost_round': 1000,  # ラウンド数を多めにしておく
    }

    model = CatBoost(params)
    # 検証用データに対する損失を使って学習課程を評価する
    # 成果物となるモデルには、それが最も良かったものを使う
    model.fit(train_pool, eval_set=[test_pool], use_best_model=True)

    y_pred = model.predict(test_pool, prediction_type='Class')

    acc = accuracy_score(y_test, y_pred)
    print('Accuracy:', acc)


if __name__ == '__main__':
    main()

上記を実行してみる。

$ python valset.py 
Learning rate set to 0.070834
0: learn: 0.5872722   test: 0.5996097  best: 0.5996097 (0)   total: 109ms    remaining: 1m 49s
1: learn: 0.5025692   test: 0.5240925  best: 0.5240925 (1)   total: 140ms    remaining: 1m 9s
2: learn: 0.4279025   test: 0.4535490  best: 0.4535490 (2)   total: 172ms    remaining: 57s
...(snip)...
997:   learn: 0.0007559   test: 0.0891592  best: 0.0872851 (771) total: 37s  remaining: 74.1ms
998:   learn: 0.0007554   test: 0.0891578  best: 0.0872851 (771) total: 37s  remaining: 37ms
999:   learn: 0.0007543   test: 0.0891842  best: 0.0872851 (771) total: 37s  remaining: 0us

bestTest = 0.08728505181
bestIteration = 771

Shrink model to first 772 iterations.
Accuracy: 0.9590643274853801

最終的に、学習データの損失において最も優れていたのは 771 ラウンドで、そのときの精度 (Accuracy) は約 0.959 だった。 先ほどよりも精度が 0.6 ポイント改善していることが分かる。

学習の過程を可視化する

続いては、モデルが学習する過程を可視化してみよう。 次のサンプルコードでは 1,000 ラウンド回した場合の、学習データと検証データに対する損失の変化を可視化している。

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

from catboost import CatBoost
from catboost import Pool

from sklearn import datasets
from sklearn.model_selection import train_test_split
from sklearn.metrics import accuracy_score
from matplotlib import pyplot as plt


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

    X_train, X_test, y_train, y_test = train_test_split(X, y,
                                                        test_size=0.3,
                                                        shuffle=True,
                                                        random_state=42,
                                                        stratify=y)

    train_pool = Pool(X_train, label=y_train)
    test_pool = Pool(X_test, label=y_test)

    params = {
        'loss_function': 'Logloss',
        'num_boost_round': 1000,
    }

    model = CatBoost(params)
    model.fit(train_pool, eval_set=[test_pool], use_best_model=True)

    y_pred = model.predict(test_pool, prediction_type='Class')

    acc = accuracy_score(y_test, y_pred)
    print('Accuracy:', acc)

    # メトリックの推移を取得する
    history = model.get_evals_result()
    # グラフにプロットする
    train_metric = history['learn']['Logloss']
    plt.plot(train_metric, label='train metric')
    eval_metric = history['validation_0']['Logloss']
    plt.plot(eval_metric, label='eval metric')

    plt.legend()
    plt.grid()
    plt.show()


if __name__ == '__main__':
    main()

上記を実行してみる。

$ python visualize.py 
Learning rate set to 0.070834
0: learn: 0.5872722   test: 0.5996097  best: 0.5996097 (0)   total: 117ms    remaining: 1m 56s
1: learn: 0.5025692   test: 0.5240925  best: 0.5240925 (1)   total: 150ms    remaining: 1m 14s
2: learn: 0.4279025   test: 0.4535490  best: 0.4535490 (2)   total: 182ms    remaining: 1m
...(snip)...
997:   learn: 0.0007559   test: 0.0891592  best: 0.0872851 (771) total: 36.6s   remaining: 73.3ms
998:   learn: 0.0007554   test: 0.0891578  best: 0.0872851 (771) total: 36.6s   remaining: 36.7ms
999:   learn: 0.0007543   test: 0.0891842  best: 0.0872851 (771) total: 36.7s   remaining: 0us

bestTest = 0.08728505181
bestIteration = 771

Shrink model to first 772 iterations.
Accuracy: 0.9590643274853801

最終的な結果は先ほどと変わらない。

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

f:id:momijiame:20190216194828p:plain

上記を見ると 200 ラウンドを待たずに検証用データに対する損失は底を打っていることが分かる。

学習が進まなくなったら打ち切る

ここまで見てきた通り、CatBoost ではとりあえず多めのラウンドを回して、最終的に良かったものを使うことができる。 とはいえ、一旦学習が進まなくなると、そこからまた改善するということはそんなにない。 大抵の場合は大して改善しない状況が続くか、過学習が進みやすい。 そこで、続いては学習が進まなくなったらそこで打ち切る Early Stopping という機能を使ってみる。 この機能は LightGBM や XGBoost でも実装されており、よく用いられるものの一つとなっている。

次のサンプルコードでは Early Stopping を用いて 1,000 ラウンド回す中で、検証用データの損失が 10 ラウンド改善しなかったらそこで学習を打ち切るようになっている。

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

from catboost import CatBoost
from catboost import Pool

from sklearn import datasets
from sklearn.model_selection import train_test_split
from sklearn.metrics import accuracy_score


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

    X_train, X_test, y_train, y_test = train_test_split(X, y,
                                                        test_size=0.3,
                                                        shuffle=True,
                                                        random_state=42,
                                                        stratify=y)

    train_pool = Pool(X_train, label=y_train)
    test_pool = Pool(X_test, label=y_test)

    params = {
        'loss_function': 'Logloss',
        'num_boost_round': 1000,
        # 検証用データの損失が既定ラウンド数減らなかったら学習を打ち切る
        'early_stopping_rounds': 10,
    }

    model = CatBoost(params)
    model.fit(train_pool, eval_set=[test_pool])

    y_pred = model.predict(test_pool, prediction_type='Class')

    acc = accuracy_score(y_test, y_pred)
    print('Accuracy:', acc)


if __name__ == '__main__':
    main()

上記を実行してみる。

$ python earlystop.py 
Learning rate set to 0.070834
0: learn: 0.5872722   test: 0.5996097  best: 0.5996097 (0)   total: 115ms    remaining: 1m 55s
1: learn: 0.5025692   test: 0.5240925  best: 0.5240925 (1)   total: 150ms    remaining: 1m 15s
2: learn: 0.4279025   test: 0.4535490  best: 0.4535490 (2)   total: 183ms    remaining: 1m
...(snip)...
136:   learn: 0.0139911   test: 0.0939635  best: 0.0932902 (128) total: 5.52s   remaining: 34.8s
137:   learn: 0.0139071   test: 0.0936872  best: 0.0932902 (128) total: 5.55s   remaining: 34.7s
138:   learn: 0.0138493   test: 0.0938802  best: 0.0932902 (128) total: 5.59s   remaining: 34.6s
Stopped by overfitting detector  (10 iterations wait)

bestTest = 0.09329017842
bestIteration = 128

Shrink model to first 129 iterations.
Accuracy: 0.9590643274853801

今度は 138 ラウンドで学習が止まったものの、最終的な汎化性能は先ほどと変わらないものが得られている。 このように Early Stopping は計算量を節約する上で重要な機能になっている。

scikit-learn インターフェースを使ってみる

CatBoost には scikit-learn のインターフェースもある。 続いてはそれを使ってみることにしよう。

以下のサンプルコードでは scikit-learn のインターフェースを備えた CatBoostClassifier を使っている。 これを用いることで使い慣れた scikit-learn のオブジェクトとして CatBoost を扱うことができる。

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

from catboost import CatBoostClassifier

from sklearn import datasets
from sklearn.model_selection import train_test_split
from sklearn.metrics import accuracy_score


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

    X_train, X_test, y_train, y_test = train_test_split(X, y,
                                                        test_size=0.3,
                                                        shuffle=True,
                                                        random_state=42,
                                                        stratify=y)

    # scikit-learn インターフェースを備えたラッパー
    model = CatBoostClassifier(num_boost_round=1000,
                               loss_function='Logloss',
                               early_stopping_rounds=10,
                               )
    model.fit(X_train, y_train, eval_set=[(X_test, y_test)])
    # 確率がほしいときは predict_proba() を使う
    y_pred = model.predict(X_test)

    acc = accuracy_score(y_test, y_pred)
    print('Accuracy:', acc)


if __name__ == '__main__':
    main()

上記を実行してみる。

$ python skapi.py 
Learning rate set to 0.070834
0: learn: 0.5872722   test: 0.5996097  best: 0.5996097 (0)   total: 112ms    remaining: 1m 51s
1: learn: 0.5025692   test: 0.5240925  best: 0.5240925 (1)   total: 143ms    remaining: 1m 11s
2: learn: 0.4279025   test: 0.4535490  best: 0.4535490 (2)   total: 176ms    remaining: 58.4s
...(snip)...
136:   learn: 0.0139911   test: 0.0939635  best: 0.0932902 (128) total: 5.8s    remaining: 36.5s
137:   learn: 0.0139071   test: 0.0936872  best: 0.0932902 (128) total: 5.83s   remaining: 36.4s
138:   learn: 0.0138493   test: 0.0938802  best: 0.0932902 (128) total: 5.88s   remaining: 36.4s
Stopped by overfitting detector  (10 iterations wait)

bestTest = 0.09329017842
bestIteration = 128

Shrink model to first 129 iterations.
Accuracy: 0.9590643274853801

結果は先ほどと変わらない。

多値分類問題を処理してみる

ここまでは二値分類問題を扱ってきた。 続いては Iris データセットを用いて多値分類問題を解かせてみよう。

次のサンプルコードでは Iris データセットを CatBoost で分類している。 検証方法は先ほどと同じホールドアウト検証で、精度 (Accuracy) について評価している。

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

from catboost import CatBoost
from catboost import Pool

from sklearn import datasets
from sklearn.model_selection import train_test_split
from sklearn.metrics import accuracy_score


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

    X_train, X_test, y_train, y_test = train_test_split(X, y,
                                                        test_size=0.3,
                                                        shuffle=True,
                                                        random_state=42,
                                                        stratify=y)

    train_pool = Pool(X_train, label=y_train)
    test_pool = Pool(X_test, label=y_test)

    params = {
        # 多値分類問題
        'loss_function': 'MultiClass',
        'num_boost_round': 1000,
        'early_stopping_rounds': 10,
    }

    model = CatBoost(params)
    model.fit(train_pool, eval_set=[test_pool])

    y_pred = model.predict(test_pool, prediction_type='Class')

    acc = accuracy_score(y_test, y_pred)
    print('Accuracy:', acc)


if __name__ == '__main__':
    main()

上記の実行結果は次の通り。

$ python multiclass.py 
0: learn: -1.0663112  test: -1.0680680 best: -1.0680680 (0)  total: 65.3ms  remaining: 1m 5s
1: learn: -1.0369337  test: -1.0404371 best: -1.0404371 (1)  total: 74.7ms  remaining: 37.3s
2: learn: -1.0094257  test: -1.0172382 best: -1.0172382 (2)  total: 78.9ms  remaining: 26.2s
...(snip)...
268:   learn: -0.0561336  test: -0.1770632 best: -0.1768477 (260)    total: 986ms    remaining: 2.68s
269:   learn: -0.0557735  test: -0.1773530 best: -0.1768477 (260)    total: 991ms    remaining: 2.68s
270:   learn: -0.0556859  test: -0.1772400 best: -0.1768477 (260)    total: 994ms    remaining: 2.67s
Stopped by overfitting detector  (10 iterations wait)

bestTest = -0.1768476949
bestIteration = 260

Shrink model to first 261 iterations.
Accuracy: 0.9111111111111111

特徴量の重要度を可視化する

CatBoost が採用している勾配ブースティング決定木は名前の通り決定木の仲間なので特徴量の重要度を取得できる。 続いては特徴量の重要度を可視化してみよう。

以下のサンプルコードでは、先ほどと同じ条件で学習したモデルから特徴量の重要度を取得してグラフにプロットしている。

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

from catboost import CatBoost
from catboost import Pool

from sklearn import datasets
from sklearn.model_selection import train_test_split
from sklearn.metrics import accuracy_score
from matplotlib import pyplot as plt


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

    X_train, X_test, y_train, y_test = train_test_split(X, y,
                                                        test_size=0.3,
                                                        shuffle=True,
                                                        random_state=42,
                                                        stratify=y)

    train_pool = Pool(X_train, label=y_train)
    test_pool = Pool(X_test, label=y_test)

    params = {
        'loss_function': 'MultiClass',
        'num_boost_round': 1000,
        'early_stopping_rounds': 10,
    }

    model = CatBoost(params)
    model.fit(train_pool, eval_set=[test_pool])

    y_pred = model.predict(test_pool, prediction_type='Class')

    acc = accuracy_score(y_test, y_pred)
    print('Accuracy:', acc)

    # 特徴量の重要度を取得する
    feature_importance = model.get_feature_importance()
    # 棒グラフとしてプロットする
    plt.figure(figsize=(12, 4))
    plt.barh(range(len(feature_importance)),
            feature_importance,
            tick_label=dataset.feature_names)

    plt.xlabel('importance')
    plt.ylabel('features')
    plt.grid()
    plt.show()


if __name__ == '__main__':
    main()

上記を実行してみる。

$ python featimp.py   
0: learn: -1.0663112  test: -1.0680680 best: -1.0680680 (0)  total: 61.7ms  remaining: 1m 1s
1: learn: -1.0369337  test: -1.0404371 best: -1.0404371 (1)  total: 67.2ms  remaining: 33.6s
2: learn: -1.0094257  test: -1.0172382 best: -1.0172382 (2)  total: 71ms remaining: 23.6s
...(snip)...
268:   learn: -0.0561336  test: -0.1770632 best: -0.1768477 (260)    total: 961ms    remaining: 2.61s
269:   learn: -0.0557735  test: -0.1773530 best: -0.1768477 (260)    total: 964ms    remaining: 2.61s
270:   learn: -0.0556859  test: -0.1772400 best: -0.1768477 (260)    total: 967ms    remaining: 2.6s
Stopped by overfitting detector  (10 iterations wait)

bestTest = -0.1768476949
bestIteration = 260

Shrink model to first 261 iterations.
Accuracy: 0.9111111111111111

得られたグラフは次の通り。

f:id:momijiame:20190216201841p:plain

上記から Petal length と Petal width が分類において有効に作用していたことが確認できる。

回帰問題を処理してみる

続いては回帰問題を扱ってみる。 以下のサンプルコードでは Boston データセットを CatBoost で回帰している。 汎化性能の確認してはホールドアウト検証で RMSE (Root Mean Squared Error) を評価している。

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

import math

from catboost import CatBoost
from catboost import Pool

from sklearn import datasets
from sklearn.model_selection import train_test_split
from sklearn.metrics import mean_squared_error


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

    X_train, X_test, y_train, y_test = train_test_split(X, y,
                                                        test_size=0.3,
                                                        shuffle=True,
                                                        random_state=42)

    train_pool = Pool(X_train, label=y_train)
    test_pool = Pool(X_test, label=y_test)

    params = {
        # 損失関数に RMSE を使う
        'loss_function': 'RMSE',
        'num_boost_round': 1000,
        'early_stopping_rounds': 10,
    }

    model = CatBoost(params)
    model.fit(train_pool, eval_set=[test_pool])

    y_pred = model.predict(test_pool)

    # 最終的なモデルの RMSE を計算する
    mse = mean_squared_error(y_test, y_pred)
    print('RMSE:', math.sqrt(mse))


if __name__ == '__main__':
    main()

上記を実行してみる。

$ python regress.py 
0: learn: 24.2681654  test: 22.5038434 best: 22.5038434 (0)  total: 75.5ms  remaining: 1m 15s
1: learn: 23.6876829  test: 21.9483221 best: 21.9483221 (1)  total: 84.6ms  remaining: 42.2s
2: learn: 23.1173979  test: 21.3856082 best: 21.3856082 (2)  total: 92.1ms  remaining: 30.6s
...(snip)...
466:   learn: 2.4850147   test: 3.3697155  best: 3.3675366 (458) total: 4.11s   remaining: 4.69s
467:   learn: 2.4833511   test: 3.3700655  best: 3.3675366 (458) total: 4.13s   remaining: 4.69s
468:   learn: 2.4828831   test: 3.3701698  best: 3.3675366 (458) total: 4.14s   remaining: 4.69s
Stopped by overfitting detector  (10 iterations wait)

bestTest = 3.36753664
bestIteration = 458

Shrink model to first 459 iterations.
RMSE: 3.367536638941265

いじょう。

Python: k-NN Feature Extraction 用のライブラリ「gokinjo」を作った

表題の通り、k-NN Feature Extraction という特徴量抽出の手法に使う「gokinjo」という Python のライブラリを作った。 今回はライブラリの使い方について紹介してみる。

github.com

k-NN Feature Extraction で得られる特徴量は、Otto Group Product Classification Challenge という Kaggle のコンペで優勝者が使ったものの一つ。 手法自体の解説は以下のブログ記事を参照のこと。 なお、ブログ記事の中でも Python のコードを書いてるけど、よりナイーブな実装になっている。

blog.amedama.jp

以降、紹介に用いる環境は次の通り。 なお、ライブラリ自体にプラットフォームへの依存はない。 また、Python のバージョンは 3.6 以降をサポートしている。

$ sw_vers 
ProductName:    Mac OS X
ProductVersion: 10.14.2
BuildVersion:   18C54
$ python -V
Python 3.7.2

インストール

ライブラリは pip を使ってインストールできる。

$ pip install gokinjo

基本的な使い方

まずは簡単な例を使って使い方を解説する。

データを可視化するために matplotlib をインストールしておく。

$ pip install matplotlib 

Python のインタプリタを起動する。

$ python

まずは次のような二次元の特徴変数と二値の目的変数を持ったダミーデータを生成する。

>>> import numpy as np
>>> x0 = np.random.rand(500) - 0.5
>>> x1 = np.random.rand(500) - 0.5
>>> X = np.array(list(zip(x0, x1)))
>>> y = np.array([1 if i0 * i1 > 0 else 0 for i0, i1 in X])

このデータを可視化すると次のようになる。 データはラベルごとに XOR 的に配置されており、線形分離できないようになっている。

>>> from matplotlib import pyplot as plt
>>> plt.scatter(X[:, 0], X[:, 1], c=y)
<matplotlib.collections.PathCollection object at 0x11d47d048>
>>> plt.show()

f:id:momijiame:20190210021002p:plain

上記のデータから、今回作ったライブラリを使って特徴量を抽出する。 抽出した特徴量は、近傍数 k とラベルの種類 C をかけた次元数を持っている。 今回であればデフォルトの近傍数 k=1 とラベル数 C=2 から k * C = 2 となって二次元のデータになる。

>>> from gokinjo import knn_kfold_extract
>>> X_knn = knn_kfold_extract(X, y)

抽出した上記のデータを可視化してみる。

plt.scatter(X_knn[:, 0], X_knn[:, 1], c=y)
plt.show()

f:id:momijiame:20190210021013p:plain

元々のデータに比べると、もうちょっとで線形分離できそうなくらいの特徴量になっていることが分かる。

Iris データセットを使った例

先ほどと同様に Iris データセットでも特徴量を抽出して可視化してみることにしよう。 サンプルコードは次の通り。

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

from sklearn import datasets
from matplotlib import pyplot as plt
from mpl_toolkits.mplot3d import Axes3D  # noqa

from gokinjo import knn_kfold_extract


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

    # k-NN 特徴量を取り出す
    extracted_features = knn_kfold_extract(X, y)

    # 取り出した特徴量を可視化する
    fig = plt.figure()
    ax = fig.add_subplot(111, projection='3d')

    ax.scatter(extracted_features[:, 0],
               extracted_features[:, 1],
               extracted_features[:, 2],
               c=y)
    ax.set_title('extracted features (3D)')

    axes = plt.gcf().get_axes()
    for axe in axes:
        axe.grid()

    plt.show()


if __name__ == '__main__':
    main()

上記に適当な名前をつけて実行する。

$ python knn_iris.py

抽出した特徴量を可視化したグラフは次の通り。 Iris データセットは多値分類問題なので k=1, C=3 より 3 次元のデータになっている。

f:id:momijiame:20190210021730p:plain

こちらも良い感じに分離できそうな特徴量が抽出できている。

Digits データセットを使った例

可視化の次は実際に抽出した特徴量を分類問題に適用してみる。 以下のサンプルコードでは Digits データセットを使って特徴量をランダムフォレストで分類している。 Digits データセットというのは 0 ~ 9 までの手書き数字の画像が含まれたもので MNIST のミニチュアみたいな感じ。 パターンとしては「生データそのまま」「k-NN 特徴量」「t-SNE 特徴量」「t-SNE -> k-NN 特徴量」で試している。

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

from sklearn import datasets
from sklearn.ensemble import RandomForestClassifier
from sklearn.model_selection import StratifiedKFold
from sklearn.model_selection import cross_validate
from sklearn.manifold import TSNE

from gokinjo import knn_kfold_extract


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

    # 下準備
    clf = RandomForestClassifier(n_estimators=100, random_state=42)
    skf = StratifiedKFold(n_splits=5, shuffle=True, random_state=42)

    # 生データをそのまま使う場合
    score = cross_validate(clf, X, y, cv=skf)
    print('mean accuracy (raw):', score['test_score'].mean())

    # k-NN 特徴量に変換した場合
    X_knn_raw = knn_kfold_extract(X, y, random_state=42)
    score = cross_validate(clf, X_knn_raw, y, cv=skf)
    print('mean accuracy (raw -> k-NN):', score['test_score'].mean())

    # t-SNE に変換した場合
    tsne = TSNE(random_state=42)
    X_tsne = tsne.fit_transform(X)
    score = cross_validate(clf, X_tsne, y, cv=skf)
    print('mean accuracy (raw -> t-SNE):', score['test_score'].mean())

    # t-SNE をさらに k-NN 特徴量に変換した場合
    X_knn_tsne = knn_kfold_extract(X_tsne, y, random_state=42)
    score = cross_validate(clf, X_knn_tsne, y, cv=skf)
    print('mean accuracy (raw -> t-SNE -> k-NN):', score['test_score'].mean())


if __name__ == '__main__':
    main()

上記を実行した結果が以下の通り。 わずかな改善ではあるものの k-NN 特徴量が効いていることが分かる。

$ python knn_digits.py 
mean accuracy (raw): 0.975493504158074
mean accuracy (raw -> k-NN): 0.9777156040302326
mean accuracy (raw -> t-SNE): 0.9888661465635314
mean accuracy (raw -> t-SNE -> k-NN): 0.9894313077402828

バックエンドの切り替え

ライブラリの特徴として、k-NN アルゴリズムの実装が切り替えられる。 デフォルトではバックエンドが scikit-learn になっているけど、オプションとして Annoy にもできる。

Annoy をバックエンドとして使うときはインストールするときに環境として [annoy] を指定する。

$ pip install "gokinjo[annoy]"

そして、knn_kfold_extract() 関数の backend オプションに annoy を指定すれば良い。

knn_kfold_extract(..., backend='annoy')

バックエンドを切り替えることで、使うデータや環境によっては高速化につながるかもしれない。

分割しながらの生成が不要な場合

ちなみに k-NN Feature Extraction は目的変数にもとづいた特徴量抽出なので、生成するときに過学習を防ぐ工夫が必要になる。 具体的には、特徴量を生成する対象のデータを学習の対象に含めてはいけないという点。 ようするに Target Encoding するときと同じように k-Fold で分割しながら特徴量を生成する必要がある。 そのため、ここまでのサンプルコードでは、データを内部的に自動で分割しながら生成してくれる knn_kfold_extract() という関数を使ってきた。

もしデータ分割がいらない、もしくは自分でやるという場合には knn_extract() という関数が使える。 典型的なユースケースは Kaggle の test データに対して特徴量を生成する場合。 このときは train データを全て使って test データの特徴量を生成すれば良いため。

以下は knn_extract() を使って自前で分割しながら特徴量を生成する場合のサンプルコード。 生成した特徴量のソートとかでコードが分かりにくくなるので、ここでは Stratified でない k-Fold をシャッフルせずに使っている。

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

import numpy as np
from sklearn import datasets
from sklearn.ensemble import RandomForestClassifier
from sklearn.model_selection import KFold
from sklearn.model_selection import StratifiedKFold
from sklearn.model_selection import cross_validate

from gokinjo import knn_extract


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

    # 生成した特徴量を保存する場所
    number_of_classes = np.unique(y)
    k = 1
    X_knn = np.empty([0, len(number_of_classes) * k])

    # Leakage を防ぐために k-Fold で分割しながら生成する
    kf = KFold(n_splits=20, shuffle=False)  # 処理の単純化のためにシャッフルなし
    for train_index, test_index in kf.split(X, y):
        X_train, X_test = X[train_index], X[test_index]
        y_train, y_test = y[train_index], y[test_index]  # noqa

        # 学習用データで学習して、テストデータに対して特徴量を生成する
        X_test_knn = knn_extract(X_train, y_train, X_test, k=k)

        # 生成した特徴量を保存する
        X_knn = np.append(X_knn, X_test_knn, axis=0)

    # 生成した特徴量をランダムフォレストで学習してみる
    clf = RandomForestClassifier(n_estimators=100, random_state=42)
    skf = StratifiedKFold(n_splits=5, shuffle=True, random_state=42)
    score = cross_validate(clf, X, y, cv=skf)
    print('mean accuracy (raw data)', score['test_score'].mean())
    score = cross_validate(clf, X_knn, y, cv=skf)
    print('mean accuracy (k-NN feature)', score['test_score'].mean())


if __name__ == '__main__':
    main()

上記の実行結果は次の通り。

$ python knn_nokfold.py 
mean accuracy (raw data) 0.975493504158074
mean accuracy (k-NN feature) 0.9782572815114076

scikit-learn インターフェース

ちなみに scikit-learn の Transformer を実装したインターフェースも用意してある。 こちらも、使うときは特徴量を生成するときに自前で k-Fold する必要がある点に注意が必要。

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

import numpy as np
from sklearn import datasets
from sklearn.ensemble import RandomForestClassifier
from sklearn.model_selection import KFold
from sklearn.model_selection import StratifiedKFold
from sklearn.model_selection import cross_validate

from gokinjo.backend_sklearn import ScikitTransformer
# from gokinjo.backend_annoy import AnnoyTransformer  # Annoy 使うなら


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

    # k-NN 特徴量を抽出する scikit-learn の Transformer として実装したやつ
    k = 1
    transformer = ScikitTransformer(n_neighbors=k)

    # 生成した特徴量を保存する場所
    number_of_classes = np.unique(y)
    X_knn = np.empty([0, len(number_of_classes) * k])

    # Leakage を防ぐために k-Fold で分割しながら生成する
    kf = KFold(n_splits=20, shuffle=False)  # 処理の単純化のためにシャッフルなし
    for train_index, test_index in kf.split(X, y):
        X_train, X_test = X[train_index], X[test_index]
        y_train, y_test = y[train_index], y[test_index]  # noqa

        # 学習用データで学習する
        transformer.fit(X_train, y_train)

        # テストデータに対して特徴量を生成する
        X_test_knn = transformer.transform(X_test)

        # 生成した特徴量を保存する
        X_knn = np.append(X_knn, X_test_knn, axis=0)

    # 生成した特徴量をランダムフォレストで学習してみる
    clf = RandomForestClassifier(n_estimators=100, random_state=42)
    skf = StratifiedKFold(n_splits=5, shuffle=True, random_state=42)
    score = cross_validate(clf, X, y, cv=skf)
    print('mean accuracy (raw data)', score['test_score'].mean())
    score = cross_validate(clf, X_knn, y, cv=skf)
    print('mean accuracy (k-NN feature)', score['test_score'].mean())


if __name__ == '__main__':
    main()

実行結果は次の通り。

$ python knn_sklearn.py 
mean accuracy (raw data) 0.975493504158074
mean accuracy (k-NN feature) 0.9782572815114076

いじょう。

Linux Bridge でタグ VLAN を使う

今回は Linux Bridge (ネットワークブリッジ) でタグ VLAN (IEEE 802.1Q) を使う方法について。 設定が足らなくてだいぶ悩んだのでメモしておく。

使った環境は次の通り。

$ cat /etc/lsb-release 
DISTRIB_ID=Ubuntu
DISTRIB_RELEASE=18.04
DISTRIB_CODENAME=bionic
DISTRIB_DESCRIPTION="Ubuntu 18.04.1 LTS"
$ uname -r
4.15.0-29-generic
$ ip -V
ip utility, iproute2-ss180129
$ sudo apt-cache show iproute2 | grep -i version
Version: 4.15.0-2ubuntu1

下準備

最初に、今回使うパッケージをインストールしておく。

$ sudo apt-get -y install iproute2 tcpdump iputils-ping

Linux Bridge でタグ VLAN を有効にする

準備ができたところで、早速本題に入る。 Linux Bridge でタグ VLAN を扱うには vlan_filtering を有効にする必要がある。 vlan_filteringip(8) で有効にできる。

例えば ip(8) を使ってブリッジを作る場合には、次のように有効にできる。 これは br0 という名前でブリッジを作るときの例。

$ sudo ip link add dev br0 type bridge vlan_filtering 1

また、作ったあとからでも次のように有効にできる。 brctl(8) とか使って作った場合にも、これでいけるはず。

$ sudo ip link add dev br0 type bridge
$ sudo ip link set dev br0 type bridge vlan_filtering 1

ブリッジを作ったら状態を UP に設定する。

$ sudo ip link set br0 up

ここからは、上記の設定が正しく動いていることを確認していく。

アクセスポートを追加する

続いては、タグ VLAN を有効にした Linux Bridge に実際にアクセスポートを追加する。 追加するネットワークインターフェースには veth インターフェースを使う。 また、あとから ping(8) で疎通を確認できるように Network Namespace をつなげておく。

まずは Network Namespace を用意する。

$ sudo ip netns add ns1

続いて veth インターフェースのペアを作成する。

$ sudo ip link add ns1-veth0 type veth peer name br0-veth0

片方の veth インターフェースを、先ほど作った Network Namespace に所属させる。

$ sudo ip link set ns1-veth0 netns ns1

所属させたネットワークインターフェースを使える状態にして IP アドレスとして 192.0.2.1/24 を付与しておく。

$ sudo ip netns exec ns1 ip link set ns1-veth0 up
$ sudo ip netns exec ns1 ip addr add dev ns1-veth0 192.0.2.1/24

veth インターフェースのもう片方は、先ほど作った Linux Bridge に追加しておく。

$ sudo ip link set br0-veth0 master br0
$ sudo ip link set br0-veth0 up

準備ができたので、やっと VLAN の設定をしていく。 その前に、まずは bridge(8) で現状を確認しておく。 デフォルトでは、次のように VLAN ID として 1 が使われるように見えている。 これは、ようするに 802.1Q VLAN を使っていない状態。

$ bridge vlan show dev br0-veth0
port    vlan ids
br0-veth0    1 PVID Egress Untagged

そこで、bridge(8) を使ってポートに VLAN を設定する。 今回は Linux Bridge に追加したポートを使った通信で VLAN ID 100 が使われるように設定してみよう。 代わりに、既存のルールは削除しておく。

$ sudo bridge vlan add vid 100 dev br0-veth0 pvid untagged
$ sudo bridge vlan del dev br0-veth0 vid 1

設定は以下のようになった。

$ bridge vlan show dev br0-veth0
port    vlan ids
br0-veth0    100 PVID Egress Untagged

通信先 (対向) を用意する

ns1 に続いて、もう一つ ns2 という名前で Network Namespace を用意して、先ほどと同じようにセットアップしておく。 これは ns1 から ping(8) を打つときの相手になる。

$ sudo ip netns add ns2
$ sudo ip link add ns2-veth0 type veth peer name br0-veth1
$ sudo ip link set ns2-veth0 netns ns2
$ sudo ip netns exec ns2 ip link set ns2-veth0 up
$ sudo ip netns exec ns2 ip addr add dev ns2-veth0 192.0.2.2/24
$ sudo ip link set br0-veth1 master br0
$ sudo ip link set br0-veth1 up
$ sudo bridge vlan add vid 100 dev br0-veth1 pvid untagged
$ sudo bridge vlan del dev br0-veth1 vid 1

通信を観察する

準備ができたので、Linux Bridge の通信を tcpdump(1) でパケットキャプチャする。

$ sudo tcpdump -nel -i br0
tcpdump: verbose output suppressed, use -v or -vv for full protocol decode
listening on br0, link-type EN10MB (Ethernet), capture size 262144 bytes

パケットキャプチャの準備ができたら、別のターミナルから ns1 から ns2 に向かって ping を打ってみよう。

$ sudo ip netns exec ns1 ping -c 3 192.0.2.2
PING 192.0.2.2 (192.0.2.2) 56(84) bytes of data.
64 bytes from 192.0.2.2: icmp_seq=1 ttl=64 time=0.114 ms
64 bytes from 192.0.2.2: icmp_seq=2 ttl=64 time=0.060 ms
64 bytes from 192.0.2.2: icmp_seq=3 ttl=64 time=0.073 ms

--- 192.0.2.2 ping statistics ---
3 packets transmitted, 3 received, 0% packet loss, time 2036ms
rtt min/avg/max/mdev = 0.060/0.082/0.114/0.024 ms

打ち終わったら tcpdump(1) を実行していたターミナルに戻る。 すると、次のように 802.1Q のデータフレームが観測できているはず。 VLAN ID にも、ちゃんと 100 が使われていることが分かる。

$ sudo tcpdump -nel -i br0
...(略)...
18:20:18.031208 b6:05:b6:b6:c6:e3 > a2:bd:24:29:d8:76, ethertype 802.1Q (0x8100), length 102: vlan 100, p 0, ethertype IPv4, 192.0.2.1 > 192.0.2.2: ICMP echo request, id 1453, seq 1, length 64
18:20:18.031240 a2:bd:24:29:d8:76 > b6:05:b6:b6:c6:e3, ethertype 802.1Q (0x8100), length 102: vlan 100, p 0, ethertype IPv4, 192.0.2.2 > 192.0.2.1: ICMP echo reply, id 1453, seq 1, length 64
...(略)

めでたしめでたし。

Python: 自作ライブラリのパッケージングについて

今回は Python で自作したライブラリなどをパッケージングして、配布できる状態にする方法について書いてみる。

現在の Python では、パッケージングに setuptools というサードパーティ製のライブラリを使うのがデファクトスタンダードになっている。 この setuptools は、pip などを使ってパッケージをインストールするときにも必要になるので、実は気づかずに使っているという場合も多いかもしれない。 また、サードパーティ製といっても PyPA (Python Packaging Authority) というコミュニティが管理しているので準公式みたいな位置づけ。

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

$ sw_vers                                      
ProductName:    Mac OS X
ProductVersion: 10.14.2
BuildVersion:   18C54
$ python -V              
Python 3.7.2
$ pip list | grep setuptools
setuptools 40.7.2

パッケージング用のサンプルコードを用意する

まずはパッケージングの対象となるサンプルコードを用意する必要がある。 そこで、今回は greet() という関数を一つだけ備えたパッケージとして example を用意した。 関数 greet() は、呼び出すと標準出力にメッセージをプリントする。

$ mkdir example
$ cat << 'EOF' > example/__init__.py
# -*- coding: utf-8 -*-

__version__ = '0.0.1'


def greet():
    """挨拶をする関数"""
    print('Hello, World!')
EOF

ちょっと紛らわしいんだけど Python においてパッケージというのは、パッケージングされたライブラリのことを意味していない。 ここでは、Python のコードが入ったディレクトリ、くらいの感覚で考えてもらえれば良い。

ちゃんとした Python におけるモジュールとパッケージという概念については以下を参照のこと。 簡単にいうとモジュールは Python ファイル (*.py) で、パッケージは __init__.py という名前のファイルが入ったディレクトリのことを指している。 blog.amedama.jp

上記で作ったパッケージは、今いるディレクトリからであれば Python の処理系からインポートして使うことができる。 これは、カレントワーキングディレクトリに Python のパスが通っているため。

$ python -c "import example; example.greet()"
Hello, World!

しかし、当然のことながら別の場所に移動してしまうと使えなくなる。 これは、先ほどのパッケージに Python のパスが通っていないため。

$ cd /tmp && python -c "import example; example.greet()" 
Traceback (most recent call last):
  File "<string>", line 1, in <module>
ImportError: No module named example

もちろん、明示的にパスを通してしまえば使うことはできる。 とはいえ、毎回こんなことをしたくはないはず。

$ PYTHONPATH=$HOME/workplace/packaging python -c "import example; example.greet()"
Hello, World!

そこで、作成したライブラリに必要なもの一式をまとめた上で、あらかじめパスの通っている場所に配置できるようにする仕組みが、今回扱うパッケージングというわけ。

サンプルコードをパッケージングする

それでは、先ほど作った example をパッケージングしてみよう。 setuptools を使ったパッケージングでは、まず setup.py という名前の Python ファイルを用意する。 このファイルは、セットアップスクリプトと呼ばれる。

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

from setuptools import setup


setup()
EOF

セットアップスクリプトの中では setuptools に含まれる関数 setup() を呼び出している。 引数には何も渡しておらず、これは比較的新しい書き方をしている。 古い書き方では setup() にパッケージに関する様々な情報を引数で渡すことになっていた。

では、パッケージに関する様々な情報は、代わりに何処で渡すのかというと setup.cfg という設定ファイルに記述する。 設定ファイルは ini フォーマットっぽい形式で書いていく。 パッケージの基本的な情報は [metadata] というセクションで扱う。

$ cat << 'EOF' > setup.cfg 
[metadata]
name = example
version = attr:example.__version__
author = Your Name
author_email = your-email@example.com
description = example is a example package
long_description = file:README.md
url = https://example.com/your/project/page
license = Apache License, Version 2.0
classifier =
    Development Status :: 1 - Planning
    Programming Language :: Python
    Intended Audience :: Developers
    Operating System :: POSIX
    Programming Language :: Python :: 2
    Programming Language :: Python :: 2.7
    Programming Language :: Python :: 3
    Programming Language :: Python :: 3.5
    Programming Language :: Python :: 3.6
    Programming Language :: Python :: 3.7
    
[options]
zip_safe = False
packages = find:
EOF

なお、この setup.cfg にメタデータなどを記述していく方式は setuptools のバージョン 30.0.3.0 (リリース日 2016年12月8日) から導入された。 この点は、インストール先となる環境の setuptools にもバージョン 30.0.3.0 以降が求められる点に注意が必要になる。 それより前のバージョンをサポートしたいときは、従来通り setup.py の中にメタデータなどを関数の引数として渡す。

setuptools.readthedocs.io

また、setup.cfg では、いくつかの項目において外部のファイルを読み込むこともできる。 例えば、先ほどの例において long_description では README.md の内容を読み込むよう指定している。 なので、ファイルを作っておこう。

$ cat << 'EOF' > README.md 
### What is this?

A long description for example project.
EOF

さて、これでパッケージングの準備ができた。 ディレクトリは、このような状態になっている。

$ ls
README.md   example     setup.cfg   setup.py

試しに pip を使ってパッケージをインストールしてみよう。 この操作は、ライブラリに必要なもの一式をまとめる作業と、インストールする作業を一気にやっているのに等しい。

$ pip install -U .

うまくいけば、次のように pip のインストール済みパッケージの一覧に表示される。

$ pip list | grep example
example    0.0.1  
$ pip show example
Name: example
Version: 0.0.1
Summary: example is a example package
Home-page: https://example.com/your/project/page
Author: Your Name
Author-email: your-email@example.com
License: Apache License, Version 2.0
Location: /Users/amedama/.virtualenvs/py37/lib/python3.7/site-packages
Requires: 
Required-by: 

また、全然別の場所でパッケージをインポートして使うこともできるようになる。 これは、pip によって作成したパッケージがパスの通った場所に配置されたため。

$ cd /tmp && python -c "import example; example.greet()"
Hello, World!

先ほど pip show したときの Location にあった通り、今回は以下のディレクトリにインストールされていた。

$ cat ~/.virtualenvs/py37/lib/python3.7/site-packages/example/__init__.py 
# -*- coding: utf-8 -*-

__version__ = '0.0.1'


def greet():
    """挨拶をする関数"""
    print('Hello, World!')

動作確認が終わったら、一旦パッケージをアンインストールしておこう。

$ pip uninstall -y example

ソースコード配布物 (sdist) にパッケージングする

先ほどはライブラリに必要なもの一式をまとめるのとインストールするのを pip コマンドで一気にやってしまった。 続いては、この工程を別々に分けてやってみよう。

まずは、基本となるソースコード配布物 (sdist) へのパッケージングから。 パッケージングは先ほど作ったセットアップスクリプトを使って sdist コマンドを実行する。

$ python setup.py sdist

すると dist というディレクトリ以下に tarball ができる。 これこそ、できあがったソースコード配布物のファイル。

$ ls dist 
example-0.0.1.tar.gz

このソースコード配布物からパッケージをインストールできる。

$ pip install dist/example-0.0.1.tar.gz
$ pip list | grep example              
example    0.0.1  

確認できたら、また一旦アンインストールしておこう。

$ pip uninstall -y example

Wheel フォーマットでパッケージングする

ソースコード配布物は基本ではあるものの、使うとちょっとめんどくさい場合もある。 具体的には Python/C API を使った拡張モジュールがライブラリに含まれているパターン。 ソースコード配布物では、拡張モジュールをビルドしていないソースコードの状態で同梱する。 すると、インストール先の環境にはそれをビルドするための開発ツール類一式が必要になる。

そこで、最近は次世代のパッケージング形式として Wheel フォーマットというものが使われ始めている。 このフォーマットでは拡張モジュールはあらかじめビルドされた状態で同梱される。 ただし、環境に依存する場合には各環境ごとに Wheel ファイルを用意する必要がある。

実際に Wheel ファイルをパッケージングしてみよう。 これにはセットアップスクリプトで bdist_wheel コマンドを実行する。

$ python setup.py bdist_wheel

すると、次の通り末尾が whl の Wheel ファイルが dist ディレクトリ以下に作られる。

$ ls dist | grep whl$
example-0.0.1-py3-none-any.whl

もちろんこの Wheel ファイルからもパッケージをインストールできる。

$ pip install dist/example-0.0.1-py3-none-any.whl
$ pip list | grep example                        
example    0.0.1

ちなみに、上記の Wheel ファイルは名前を見て分かる通り Python 3 専用としてビルドされている。 もし、Python 2 でも動く場合には setup.cfg[wheel] セクションについて universal フラグを有効にする。

$ cat << 'EOF' > setup.cfg 
[metadata]
name = example
version = attr:example.__version__
author = Your Name
author_email = your-email@example.com
description = example is a example package
long_description = file:README.md
url = https://example.com/your/project/page
license = Apache License, Version 2.0
classifier =
    Development Status :: 1 - Planning
    Programming Language :: Python
    Intended Audience :: Developers
    Operating System :: POSIX
    Programming Language :: Python :: 2
    Programming Language :: Python :: 2.7
    Programming Language :: Python :: 3
    Programming Language :: Python :: 3.5
    Programming Language :: Python :: 3.6
    Programming Language :: Python :: 3.7
    
[options]
zip_safe = False
packages = find:

[wheel]
universal = 1
EOF

この状態でビルドすると Python 2/3 両対応の Wheel ファイルになる。

$ python setup.py bdist_wheel
$ ls dist | grep whl$
example-0.0.1-py2.py3-none-any.whl
example-0.0.1-py3-none-any.whl

動作が確認できたら、また一旦アンインストールしておこう。

$ pip uninstall -y example

パッケージングした成果物を PyPI に登録する

さて、パッケージングできるようになると、実は成果物を PyPI に登録して一般に公開できる。 この作業には PyPA 製の Twine というツールを使うのがおすすめ。 詳しくは、以下を参照のこと。

blog.amedama.jp

依存ライブラリを指定する

さて、話をちょっと戻して、ここからはパッケージングにおける色々なユースケースについて見ていく。

まずは、自作ライブラリが別のライブラリに依存している場合について。 この場合は [options] セクションに install_requires という項目で指定する。 例えば requests を依存ライブラリとして追加してみよう。

$ cat << 'EOF' > setup.cfg 
[metadata]
name = example
version = attr:example.__version__
author = Your Name
author_email = your-email@example.com
description = example is a example package
long_description = file:README.md
url = https://example.com/your/project/page
license = Apache License, Version 2.0
classifier =
    Development Status :: 1 - Planning
    Programming Language :: Python
    Intended Audience :: Developers
    Operating System :: POSIX
    Programming Language :: Python :: 2
    Programming Language :: Python :: 2.7
    Programming Language :: Python :: 3
    Programming Language :: Python :: 3.5
    Programming Language :: Python :: 3.6
    Programming Language :: Python :: 3.7
    
[options]
zip_safe = False
packages = find:
install_requires =
  requests

[wheel]
universal = 1
EOF

この状態でインストールしてみよう。

$ pip install -U .

すると、次のように requests が一緒にインストールされたことが分かる。

$ pip list | egrep "(example|requests)"
example    0.0.1     
requests   2.21.0    

環境に依存した依存ライブラリを指定する

続いては環境によって必要だったり不要だったりする依存ライブラリの指定について。 例えばリレーショナルデータベースを扱うアプリケーションだと、接続先が MySQL なのか PostgreSQL なのかで必要なドライバが変わる。

例として接続先が MySQL の環境なら mysqlclient をインストールする、という状況を想定してみよう。 この場合、[options.extras_require] セクションに環境名と必要なライブラリを指定していく。

$ cat << 'EOF' > setup.cfg 
[metadata]
name = example
version = attr:example.__version__
author = Your Name
author_email = your-email@example.com
description = example is a example package
long_description = file:README.md
url = https://example.com/your/project/page
license = Apache License, Version 2.0
classifier =
    Development Status :: 1 - Planning
    Programming Language :: Python
    Intended Audience :: Developers
    Operating System :: POSIX
    Programming Language :: Python :: 2
    Programming Language :: Python :: 2.7
    Programming Language :: Python :: 3
    Programming Language :: Python :: 3.5
    Programming Language :: Python :: 3.6
    Programming Language :: Python :: 3.7
    
[options]
zip_safe = False
packages = find:
install_requires =
  requests

[options.extras_require]
mysql =
  mysqlclient

[wheel]
universal = 1
EOF

この状態で、環境に mysql を指定してパッケージをアップデートしてみよう。

$ pip install -U ".[mysql]"

すると、mysqlclient がインストールされることが分かる。

$ pip list | grep mysqlclient
mysqlclient 1.4.1     

ソースコード以外のファイルを成果物に含める

続いては、ソースコード以外のファイルを成果物に含める方法について。 実は、デフォルトでは成果物にソースコード以外のファイルは含まれない。 試しに実験してみよう。

まずはパッケージに greet_from_file() という関数を追加する。 これはテキストファイルから読み込んだ内容を使ってメッセージをプリントする関数になっている。

$ cat << 'EOF' > example/__init__.py
# -*- coding: utf-8 -*-

from __future__ import print_function

import os

__version__ = '0.0.1'


def greet():
    """挨拶をする関数"""
    print('Hello, World!')


def greet_from_file():
    """テキストファイルの内容を使って挨拶する関数"""
    module_dir = os.path.dirname(__file__)
    filepath = os.path.join(module_dir, 'message.txt')
    with open(filepath, mode='r') as fp:
        print(fp.read(), end='')
EOF

続いて、表示するメッセージの元となるファイルを用意する。

$ cat << 'EOF' > example/message.txt 
Hello, World!
EOF

ローカルでは、これで動くようになる。

$ python -c "import example; example.greet_from_file()"
Hello, World!

では、インストールした上ではどうだろうか。

$ pip install -U .

試すと、そんなファイルないよと怒られる。 パッケージングした成果物の中にテキストファイルが含まれていないためだ。

$ cd /tmp && python -c "import example; example.greet_from_file()"
Traceback (most recent call last):
  File "<string>", line 1, in <module>
  File "/Users/amedama/.virtualenvs/py37/lib/python3.7/site-packages/example/__init__.py", line 19, in greet_from_file
    with open(filepath, mode='r') as fp:
FileNotFoundError: [Errno 2] No such file or directory: '/Users/amedama/.virtualenvs/py37/lib/python3.7/site-packages/example/message.txt'

ソースコード以外のファイルを成果物に含めるには、まず setup.cfg[options] セクションにおいて include_package_data の項目を有効にする。

$ cat << 'EOF' > setup.cfg 
[metadata]
name = example
version = attr:example.__version__
author = Your Name
author_email = your-email@example.com
description = example is a example package
long_description = file:README.md
url = https://example.com/your/project/page
license = Apache License, Version 2.0
classifier =
    Development Status :: 1 - Planning
    Programming Language :: Python
    Intended Audience :: Developers
    Operating System :: POSIX
    Programming Language :: Python :: 2
    Programming Language :: Python :: 2.7
    Programming Language :: Python :: 3
    Programming Language :: Python :: 3.5
    Programming Language :: Python :: 3.6
    Programming Language :: Python :: 3.7
    
[options]
zip_safe = False
include_package_data = True
packages = find:
install_requires =
  requests

[options.extras_require]
mysql =
  mysqlclient

[wheel]
universal = 1
EOF

その上で MANIFEST.in というファイルを用意する。 ここに、成果物に含めるソースコード以外のファイルを指定していく。

$ cat << 'EOF' > MANIFEST.in 
include example/message.txt
EOF

この状態で、もう一度試してみよう。

$  pip install -U .
$ cd /tmp && python -c "import example; example.greet_from_file()"
Hello, World!

今度はエラーにならず実行できた。

コマンドを追加する

続いてはパッケージを追加したときに新たにコマンドが使えるようにする方法について。

今回はコマンド用に別の設定ファイルを用意することにした。 まずは setup.cfg[options] セクションに entry_points という項目を作る。

$ cat << 'EOF' > setup.cfg 
[metadata]
name = example
version = attr:example.__version__
author = Your Name
author_email = your-email@example.com
description = example is a example package
long_description = file:README.md
url = https://example.com/your/project/page
license = Apache License, Version 2.0
classifier =
    Development Status :: 1 - Planning
    Programming Language :: Python
    Intended Audience :: Developers
    Operating System :: POSIX
    Programming Language :: Python :: 2
    Programming Language :: Python :: 2.7
    Programming Language :: Python :: 3
    Programming Language :: Python :: 3.5
    Programming Language :: Python :: 3.6
    Programming Language :: Python :: 3.7
    
[options]
zip_safe = False
include_package_data = True
packages = find:
entry_points = file:entry_points.cfg
install_requires =
  requests

[options.extras_require]
mysql =
  mysqlclient

[wheel]
universal = 1
EOF

その上で、指定したのと同じ設定ファイルを用意する。 以下では example-greet というコマンドを実行したとき example パッケージの greet() 関数が呼ばれるようにしている。

$ cat << 'EOF' > entry_points.cfg
[console_scripts]
example-greet = example:greet
EOF

それでは、パッケージを更新してみよう。

$ pip install -U .

すると example-greet というコマンドが使えるようになっている。

$ example-greet
Hello, World!

テストコードを成果物に含めない

パッケージングしたくなるようなコードには、もちろんテストコードを書くことになる。 続いてはテストコードを成果物に含めない方法とともに、具体的なオペレーションを紹介してみる。

まずはテストをする環境向けの依存ライブラリを [options.extras_require] セクションで指定する。 テストフレームワークには pytest を使うことにした。 同時に、テストを成果物に含めないように [options.packages.find] セクションで tests というディレクトリを探索対象から除外する。

$ cat << 'EOF' > setup.cfg 
[metadata]
name = example
version = attr:example.__version__
author = Your Name
author_email = your-email@example.com
description = example is a example package
long_description = file:README.md
url = https://example.com/your/project/page
license = Apache License, Version 2.0
classifier =
    Development Status :: 1 - Planning
    Programming Language :: Python
    Intended Audience :: Developers
    Operating System :: POSIX
    Programming Language :: Python :: 2
    Programming Language :: Python :: 2.7
    Programming Language :: Python :: 3
    Programming Language :: Python :: 3.5
    Programming Language :: Python :: 3.6
    Programming Language :: Python :: 3.7
    
[options]
zip_safe = False
include_package_data = True
packages = find:
entry_points = file:entry_points.cfg
install_requires =
  requests

[options.extras_require]
mysql =
  mysqlclient
testing =
  pytest

[options.packages.find]
exclude =
  tests

[wheel]
universal = 1
EOF

続いてはテストコードを入れるパッケージを用意する。 中身でやっているのは、あまり本質的ではないことなので気にしなくて良いと思う。

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

import os
import sys

import pytest

from example import greet

if sys.version_info >= (3, 0, 0):
    # Python 3
    from io import StringIO
else:
    # Python 2
    from StringIO import StringIO


def test_greet():
    """挨拶は大事"""
    # 標準出力を入れ替える
    io = StringIO()
    sys.stdout = io

    # 挨拶する
    greet()

    # 挨拶した内容を確認する
    assert io.getvalue() == ('Hello, World!' + os.linesep)


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

準備ができたら、テスト環境向けにインストールする。

$ pip install -U ".[testing]"

修正したときの手間などを考えるとパッケージ本体は普段はアンインストールしておいた方が良いかも。

$ pip uninstall -y example

テストを走らせるにはテストランナーを実行する。

$ py.test
====================================== test session starts ======================================
platform darwin -- Python 3.7.2, pytest-4.2.0, py-1.7.0, pluggy-0.8.1
rootdir: /Users/amedama/Documents/temporary/packaging, inifile:
collected 1 item                                                                                

tests/test_example.py .                                                                   [100%]

=================================== 1 passed in 0.02 seconds ====================================

テストが実行できることが分かったので、続いては成果物の中身を確認してみよう。

$ rm -rf dist
$ python setup.py sdist
$ tar xvf dist/example-0.0.1.tar.gz -C dist
$ find dist/example-0.0.1 
dist/example-0.0.1
dist/example-0.0.1/PKG-INFO
dist/example-0.0.1/example.egg-info
dist/example-0.0.1/example.egg-info/PKG-INFO
dist/example-0.0.1/example.egg-info/not-zip-safe
dist/example-0.0.1/example.egg-info/SOURCES.txt
dist/example-0.0.1/example.egg-info/entry_points.txt
dist/example-0.0.1/example.egg-info/requires.txt
dist/example-0.0.1/example.egg-info/top_level.txt
dist/example-0.0.1/example.egg-info/dependency_links.txt
dist/example-0.0.1/example
dist/example-0.0.1/example/__init__.py
dist/example-0.0.1/example/message.txt
dist/example-0.0.1/MANIFEST.in
dist/example-0.0.1/README.md
dist/example-0.0.1/setup.py
dist/example-0.0.1/setup.cfg

成果物の中には tests というディレクトリが含まれていないことが分かる。

まとめ

  • Python のパッケージングには setuptools というライブラリを使う
  • パッケージングするときはセットアップスクリプト (setup.py) を用意する
  • 最近 (>= 30.0.3.0) の書き方では、なるべく別の設定ファイル (setup.cfg) に内容を記述する
  • パッケージングの成果物にはソースコード配布物 (sdist) と Wheel (whl) がある

Python: Unix における python* コマンドと処理系のバージョンについて (PEP394)

今回は Unix ライクなシステムにおける python* コマンドの振る舞いと、処理系のバージョンについて色々と書いてみる。

きっかけは、以下のブログ記事を目にしたため。

rheb.hatenablog.com

上記のブログでは Red Hat Enterprise Linux 8 に搭載される予定の Python と、それを扱うコマンド群について紹介されている。 そして、RHEL8 がそのような仕様に落ち着いた理由として以下の PEP (Python Enhancement Proposal) が挙げられている。

www.python.org

この PEP394 というドキュメントには、Unix ライクのシステムにおいて推奨される python* コマンドの振る舞いなどが記述されている。 そこで、今回は PEP394 に書かれている内容について紹介したい。 先のブログでは、話の切り口が「RHEL8 における仕様」なのでやむを得ないものの、やや説明が不足しており補足したい点もあった。

python* コマンドで実行される処理系のバージョンについて

まず、PEP394 には python* コマンドを実行したときに、どのバージョンの処理系が呼び出されるべきなのか書かれている。 例えば、次のようにコマンド名でバージョンを明示的に指定したときは、そのバージョンの処理系が呼ばれることになる。 もちろん「そのバージョンの処理系がインストールされていれば」という前提のもとで。

$ python2  # => Python 2.x の処理系が使われる
$ python3  # => Python 3.x の処理系が使われる

問題となるのは、バージョンが明示的に指定されていないとき。 一体、どのバージョンの処理系が呼び出されるべきなのだろうか。 その答えは「現時点では Python 2.x の処理系が使われることが推奨されている」になる。

$ python  # => 現時点では Python 2.x の処理系が使われることが推奨されている

現時点では?

太字にした通り、この挙動が推奨されているのは、あくまで現時点 (2019-01) の PEP394 ではという注釈つきになる。 なぜなら PEP394 には Future Changes to this Recommendation (この勧告に対する将来の変更) という項目があるため。

この項目には、次のような記述がある。(カッコ内は拙訳)

This recommendation will be periodically reviewed over the next few years, and updated when the core development team judges it appropriate. As a point of reference, regular maintenance releases for the Python 2.7 series will continue until at least 2020. (この勧告は将来の数年間に渡って定期的に見直されます。そして、コア開発チームが妥当と判断したときに更新されます。参考までに、Python 2.7 系の定常的なメンテナンスリリースは少なくとも 2020 年まで続きます。)

つまり、将来的にはバージョン指定なしの python コマンドで Python 3.x の処理系が使われることを推奨する内容に更新される余地がある。

例外について

ところで、上記の振る舞いは一部のディストリビューションと仮想環境では例外として扱われている。

一部のディストリビューションというのは、例えばこの PEP が勧告される以前から Python 3 移行を始めた、ある意味で優秀なコミュニティもあったりするため。

www.archlinux.org

もう一つの仮想環境というのは venvvirtualenv を使った環境を指している。 仮想環境の中では、環境を作るときに指定されたか、あるいはデフォルトの処理系が python コマンドで使われる。

$ python3 -m venv myvenv 
$ source myvenv/bin/activate
(myvenv) $ python -V
Python 3.7.2

シェバン (Shebang) について

PEP394 には、シェバン (Shebang) に関する記載もある。

シェバンにおいては、特定のバージョンの Python でしか動かないモジュールならバージョンを指定することが推奨されている。 例えば、Python 2 でしか動かないなら以下のようにする。

#!/usr/bin/env python2
...

Python 3 でしか動かないなら、こう。

#!/usr/bin/env python3
...

バージョン指定のないシェバンは、モジュールが両方のバージョンで動く場合に用いることが推奨されている。

#!/usr/bin/env python
...

インタプリタから別のインタプリタを起動する場合

また、subprocess なんかでインタプリタの中から別のインタプリタを呼ぶときは sys.executable の内容を使うのが良い。 ここには自身の処理系のパスが格納されている。

$ python3 -c "import sys; print(sys.executable)"
/usr/local/opt/python/bin/python3.7

特定のコマンド名 (python2, python3, python, etc) を使うと、誤って別の処理系を呼び出したりして思わぬバグが生じる恐れがある。

まとめ

  • 前提: PEP394 の勧告は将来的に変更される余地がある
  • 各コマンドで起動する処理系のバージョン
    • python2 # => Python 2.x
    • python3 # => Python 3.x
    • python # => 今のところ Python 2.x
  • 上記の例外
    • 一部のディストリビューション (ArchLinux など)
    • 仮想環境 (venv, virtualenv)
  • シェバン (Shebang) を書くとき
    • python2 # => Python 2.x のみで動作する
    • python3 # => Python 3.x のみで動作する
    • python # => 両方のバージョンで動作する
  • インタプリタの中から別のインタプリタを起動するとき
    • sys.executable を使う

いじょう。