CUBE SUGAR CONTAINER

技術系のこと書きます。

Mac で UVC 対応の Web カメラを使ってみる

ちょっとした理由があって、外付けの Web カメラを Mac で使いたくなった。 ただ、大抵の Mac にはインカメラが標準で付いているせいか、別付けの Web カメラを使ってる人があんまりいないみたい。 なので、使えたよって記録を備忘録としてここに書き残しておく。

ちなみに macOS はバージョン 10.4 (Tiger) 以降から UVC (USB Video Class) に対応している。 これは USB でカメラの映像をやり取りするための通信規格で、機器が対応していれば OS 共通のドライバで動かせる。 なので、メーカーが macOS の対応を公式に謳っていなくても、理屈の上では UVC に対応していれば動かせるはず。 今回使ってみたのも UVC 対応の Web カメラで、サンワサプライの CMS-V40BK というモデル。 なお、公式には macOS への対応は謳われていない。

使うときは、特に何をするでもなくカメラから伸びている USB を Mac につなぐだけで認識した。

$ ioreg -p IOUSB
+-o Root  <class IORegistryEntry, id 0x100000100, retain 15>
  +-o AppleUSBXHCI Root Hub Simulation@14000000  <class AppleUSBRootHubDevice, id 0x100005fc6, registered, matched, active, busy 0 (2 ms), retain 15>
    +-o USB Camera@14100000  <class AppleUSBDevice, id 0x100006662, registered, matched, active, busy 0 (22 ms), retain 30>

試しに OpenCV のプログラムを書いてカメラの画像をキャプチャしてみることにした。

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

import cv2


def main():
    # カメラの識別子
    CAMERA_ID = 0
    camera = cv2.VideoCapture(CAMERA_ID)

    print('Press space key if you want to save the image')

    while True:
        ret, frame = camera.read()
        if ret is None:
            break

        cv2.imshow('capture', frame)

        key = cv2.waitKey(1) & 0xFF

        if key == 32:
            # スペースキーが押下された場合
            img_name = 'camera_capture.png'
            cv2.imwrite(img_name, frame)
            print('captured')

        if key == ord('q'):
            # Q キーで終了する
            print('bye bye')
            break

    # 後始末
    camera.release()
    cv2.destroyAllWindows()


if __name__ == '__main__':
    main()

動作確認した環境は次の通り。

$ sw_vers
ProductName:    Mac OS X
ProductVersion: 10.14.4
BuildVersion:   18E226
$ python -V                                    
Python 3.7.3
$ python -c "import cv2;print(cv2.__version__)"
4.1.0

撮影した画像は以下。 だいぶ横長になる。 ちなみに、オートフォーカスはついてないみたい。 今回の用途では不要なので気にしなかった。

f:id:momijiame:20190422002027p:plain

いじょう。

f:id:momijiame:20190421204536j:plain:w320

Python: RFE (Recursive Feature Elimination) で特徴量を選択してみる

今回は RFE (Recursive Feature Elimination) と呼ばれる手法を使って特徴量選択 (Feature Selection) してみる。 教師データの中には、モデルの性能に寄与しない特徴量が含まれている場合がある。 アルゴリズムがノイズに対して理想的にロバストであれば、有効な特徴量だけを読み取って学習するため特徴量選択は不要かもしれない。 しかしながら、現実的にはそのような仮定を置くことが難しい場合があると思う。 そこで、元の特徴量からモデルの性能に寄与する部分集合を取り出す作業を特徴量選択という。

特徴量選択の手法には、以下の 3 つがあるようだ。

  • フィルター法 (Filter Method)
    • 統計的な物差しにもとづいて特徴量を評価する
  • ラッパー法 (Wrapper Method)
    • 機械学習のモデルを用いて特徴量を評価する
  • 組み込み法 (Embedding Method)
    • モデルが学習するタイミングで特徴量を評価する

RFE は、上記でいうとラッパー法に分類される。 実際に機械学習のモデルを用いて、部分集合を学習・評価した際に性能が上がるか下がるか確認していく。 フィルター法や組み込み法に比べると必要な計算量は多い。

使った環境は次の通り。

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

下準備

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

$ pip install scikit-learn matplotlib

無意味な特徴量が減ることで性能は上がるのか

特徴量を実際に選択してみる前に、そもそも無意味な特徴量を減らすことでモデルの性能は上がるのか確かめておく。

以下のサンプルコードでは二値分類問題のダミーデータを用意している。 データは 100 次元の特徴量を持つものの、実際に意味を持ったものは 5 次元しか含まれていない。 そのデータを使ってランダムフォレストを学習させて 5-Fold CV で AUC (Area Under the Curve) について評価している。 100 次元全てを使った場合と 5 次元だけ使った場合で、どのような差が出るだろうか。

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

from sklearn.datasets import make_classification
from sklearn.ensemble import RandomForestClassifier
from sklearn.metrics import roc_auc_score
from sklearn.model_selection import cross_val_predict
from sklearn.model_selection import StratifiedKFold


def main():
    # 疑似的な教師信号を作るためのパラメータ
    args = {
        # 1,000 点のデータ
        'n_samples': 1000,
        # データは 100 次元の特徴量を持つ
        'n_features': 100,
        # その中で意味のあるものは 5 次元
        'n_informative': 5,
        # 重複や繰り返しはなし
        'n_redundant': 0,
        'n_repeated': 0,
        # 二値分類問題
        'n_classes': 2,
        # 生成に用いる乱数
        'random_state': 42,
        # 特徴の順序をシャッフルしない (先頭の次元が informative になる)
        'shuffle': False,
    }
    # 教師信号を作る
    X, y = make_classification(**args)

    # 分類器にランダムフォレストを使う
    clf = RandomForestClassifier(n_estimators=100,
                                 random_state=42)

    # Stratified 5-Fold CV で OOF Prediction (probability) を作る
    skf = StratifiedKFold(n_splits=5,
                          shuffle=True,
                          random_state=42)
    y_pred = cross_val_predict(clf, X, y,
                               cv=skf,
                               method='predict_proba')

    # AUC のスコアを確認する
    metric = roc_auc_score(y, y_pred[:, 1])
    print('All used AUC:', metric)

    # 先頭 5 次元だけを使ったときの AUC スコアを確認する
    y_pred = cross_val_predict(clf, X[:, :5], y,
                               cv=skf,
                               method='predict_proba')
    metric = roc_auc_score(y, y_pred[:, 1])
    print('Ideal AUC:', metric)


if __name__ == '__main__':
    main()

上記の実行結果は次の通り。 全ての特徴量を使った場合の AUC が 0.947 なのに対して、意味を持った特徴量だけを使った場合には 0.981 というスコアが出ている。 あきらかに、意味を持った特徴量だけを使った方が性能が上がっている。

$ python features.py                 
All used AUC: 0.9475844521611112
Ideal AUC: 0.9811272823286553

特徴量の重要度を確認する

念のため、ランダムフォレストが特徴量の重要度 (Feature Importance) をどのように認識したかを確認しておこう。 以下のサンプルコードでは、全てのデータを使って学習した際の特徴量の重要度を TOP20 で可視化している。 ちなみに、意味のある特徴量は先頭の 5 次元に固まっている。

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

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


def main():
    args = {
        'n_samples': 1000,
        'n_features': 100,
        'n_informative': 5,
        'n_redundant': 0,
        'n_repeated': 0,
        'n_classes': 2,
        'random_state': 42,
        'shuffle': False,
    }
    X, y = make_classification(**args)

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

    # 全データを使って学習する
    clf.fit(X, y)

    # ランダムフォレストから特徴量の重要度を取り出す
    importances = clf.feature_importances_
    importance_std = np.std([tree.feature_importances_
                             for tree in clf.estimators_],
                            axis=0)
    indices = np.argsort(importances)[::-1]

    # TOP 20 の特徴量を出力する
    rank_n = min(X.shape[1], 20)
    print('Feature importance ranking (TOP {rank_n})'.format(rank_n=rank_n))
    for i in range(rank_n):
        params = {
            'rank': i + 1,
            'idx': indices[i],
            'importance': importances[indices[i]]
        }
        print('{rank}. feature {idx:02d}: {importance}'.format(**params))

    # TOP 20 の特徴量の重要度を可視化する
    plt.figure(figsize=(8, 32))
    plt.barh(range(rank_n),
             importances[indices][:rank_n],
             color='g',
             ecolor='r',
             yerr=importance_std[indices][:rank_n],
             align='center')
    plt.yticks(range(rank_n), indices[:rank_n])
    plt.xlabel('Features')
    plt.ylabel('Importance')
    plt.grid()
    plt.show()


if __name__ == '__main__':
    main()

上記の実行結果は次の通り。 重要度の上位には、ちゃんと意味もある 5 次元 (00 ~ 04) が選ばれている。 ただ、重要度の桁は違うものの、その他の特徴量にもある程度の重要性を見出してしまっているようだ。

$ python importances.py
Feature importance ranking (TOP 20)
1. feature 00: 0.137486590660634
2. feature 01: 0.08702192608775006
3. feature 03: 0.07472282910658508
4. feature 04: 0.07038842117095266
5. feature 02: 0.02259020059710678
6. feature 94: 0.008822020226963349
7. feature 79: 0.00872048865547228
8. feature 89: 0.008393957575172837
9. feature 49: 0.00833328823498188
10. feature 12: 0.008242356321677316
11. feature 82: 0.008114625429010737
12. feature 51: 0.008104053552752165
13. feature 55: 0.008094847876625212
14. feature 66: 0.008064408438414555
15. feature 47: 0.00785300220608914
16. feature 17: 0.0077133341443931394
17. feature 87: 0.007494065077920688
18. feature 41: 0.007424822763282093
19. feature 43: 0.007354073249384186
20. feature 24: 0.007233824574872333

上記をグラフとして可視化したものは以下の通り。

f:id:momijiame:20190420202654p:plain

このように、モデルがその特徴量を重要と判断しているからといって、本当にそれが性能の向上に寄与するとは限らない。

RFE を使って特徴量を選択する

前置きが長くなったけど、そろそろ実際に RFE で特徴量を選択してみよう。 RFE は scikit-learn に実装がある。

scikit-learn.org

以下のサンプルコードでは RFE とランダムフォレストを使って 100 次元の特徴量から 5 次元を選択している。 また、選択した特徴量の重要度を出力すると共に、選択した特徴量についてホールドアウト検証で性能を確認している。

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

from sklearn.datasets import make_classification
from sklearn.ensemble import RandomForestClassifier
from sklearn.feature_selection import RFE
from sklearn.metrics import roc_auc_score
from sklearn.model_selection import train_test_split
from sklearn.model_selection import cross_val_predict
from sklearn.model_selection import StratifiedKFold


def main():
    args = {
        'n_samples': 1000,
        'n_features': 100,
        'n_informative': 5,
        'n_redundant': 0,
        'n_repeated': 0,
        'n_classes': 2,
        'random_state': 42,
        'shuffle': False,
    }
    X, y = make_classification(**args)

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

    # Recursive Feature Elimination
    rfe = RFE(estimator=clf,
              # 有効そうな 5 つの特徴量を取り出す
              n_features_to_select=5,
              verbose=1)

    # 特徴量の選択と評価のためにデータを分割する
    # 計算量が許すのであれば k-Fold した方が bias は小さくなるはず
    X_train, X_eval, y_train, y_eval = train_test_split(X, y,
                                                        shuffle=True,
                                                        random_state=42)

    # RFE を学習する
    rfe.fit(X_eval, y_eval)

    # RFE による特徴量の評価 (ランキング)
    print('Feature ranking by RFF:', rfe.ranking_)

    # RFE で選択された特徴量だけを取り出す
    X_train_selected = X_train[:, rfe.support_]

    # Stratified 5-Fold CV で OOF Prediction (probability) を作る
    skf = StratifiedKFold(n_splits=5,
                          shuffle=True,
                          random_state=42)
    y_pred = cross_val_predict(clf, X_train_selected, y_train,
                               cv=skf,
                               method='predict_proba')

    # AUC のスコアを確認する
    metric = roc_auc_score(y_train, y_pred[:, 1])
    print('RFE selected features AUC:', metric)


if __name__ == '__main__':
    main()

実行結果は次の通り。 意味がある特徴量である先頭の 5 次元がランキングで 1 位となっている。 どうやら、ちゃんと有効な特徴量が選択できているようだ。

$ python rfe.py 
Fitting estimator with 100 features.
Fitting estimator with 99 features.
Fitting estimator with 98 features.
Fitting estimator with 97 features.
Fitting estimator with 96 features.
Fitting estimator with 95 features.
...(snip)...
Fitting estimator with 10 features.
Fitting estimator with 9 features.
Fitting estimator with 8 features.
Fitting estimator with 7 features.
Fitting estimator with 6 features.
Feature ranking by RFF: [ 1  1  1  1  1 59 14 44 79 61 58 67 70 41 77 33 51 24  3 34  9 71 63 91
 73 47 53 26 86 17 13 94 88 31  2 62 19 25 11 48 95 84 56 10 68 18 74 45
 39 15 43 96 32 80 52 36  4 57 82 50 27 78 69 85 93 65 29  7 30 55 46 49
 20 60 72 16 42 92 23 64 81 22 38 90 66 28 35 76 83  8 37 21 54 40 87 75
  5 12  6 89]
RFE selected features AUC: 0.9770972008875235

AUC が理想的な状況よりも落ちているのは、ホールドアウト検証なので使えるデータが減っているためだろうか。

選択する特徴量の数を最適化する

ところで、RFE では選択する特徴量の数をあらかじめ指定する必要がある。 先ほどのサンプルコードでは、あらかじめ有効な特徴量の数が分かっていたので 5 を指定した。 しかしながら、そもそもいくつ取り出すのが良いのかはあらかじめ分かっていない状況の方が多いだろう。

選択する特徴量の数は、一体どうやって決めれば良いのだろうか。 悩んだものの、そもそもハイパーパラメータの一種と捉えて最適化してしまえば良いのではと考えた。 そこで、続いては RFE で選択する特徴量の数を Optuna を使って最適化してみよう。

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

$ pip install optuna

以下のサンプルコードでは、AUC が最大になるように取り出す特徴量の数を最適化している。

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

from functools import partial

import optuna
from sklearn.datasets import make_classification
from sklearn.ensemble import RandomForestClassifier
from sklearn.feature_selection import RFE
from sklearn.metrics import roc_auc_score
from sklearn.model_selection import train_test_split
from sklearn.model_selection import cross_val_predict
from sklearn.model_selection import StratifiedKFold


def objective(X, y, trial):
    """最適化する目的関数"""
    clf = RandomForestClassifier(n_estimators=100,
                                 random_state=42)

    # RFE で取り出す特徴量の数を最適化する
    n_features_to_select = trial.suggest_int('n_features_to_select', 1, 100),
    rfe = RFE(estimator=clf,
              n_features_to_select=n_features_to_select)

    X_train, X_eval, y_train, y_eval = train_test_split(X, y,
                                                        shuffle=True,
                                                        random_state=42)
    rfe.fit(X_eval, y_eval)

    X_train_selected = X_train[:, rfe.support_]
    skf = StratifiedKFold(n_splits=5,
                          shuffle=True,
                          random_state=42)
    y_pred = cross_val_predict(clf, X_train_selected, y_train,
                               cv=skf,
                               method='predict_proba')

    metric = roc_auc_score(y_train, y_pred[:, 1])
    return metric


def main():
    args = {
        'n_samples': 500,
        'n_features': 100,
        'n_informative': 5,
        'n_redundant': 0,
        'n_repeated': 0,
        'n_classes': 2,
        'random_state': 42,
        'shuffle': False,
    }
    X, y = make_classification(**args)

    # 目的関数にデータを適用する
    f = partial(objective, X, y)

    # Optuna で取り出す特徴量の数を最適化する
    study = optuna.create_study(direction='maximize')

    # 20 回試行する
    study.optimize(f, n_trials=20)

    # 発見したパラメータを出力する
    print('params:', study.best_params)


if __name__ == '__main__':
    main()

実行結果は次の通り。 ちゃんと選択すべき特徴量の数が 5 として最適化できた。

$ python rfeopt.py 
[I 2019-04-20 19:42:22,874] Finished trial#0 resulted in value: 0.9571904165718188. Current best value is 0.9571904165718188 with parameters: {'n_features_to_select': 23}.
[I 2019-04-20 19:42:29,757] Finished trial#1 resulted in value: 0.9299169132711131. Current best value is 0.9571904165718188 with parameters: {'n_features_to_select': 23}.
[I 2019-04-20 19:42:35,810] Finished trial#2 resulted in value: 0.9395487138629638. Current best value is 0.9571904165718188 with parameters: {'n_features_to_select': 23}.
[I 2019-04-20 19:42:45,647] Finished trial#3 resulted in value: 0.8863390621443205. Current best value is 0.9571904165718188 with parameters: {'n_features_to_select': 23}.
[I 2019-04-20 19:42:56,298] Finished trial#4 resulted in value: 0.9714318233553381. Current best value is 0.9714318233553381 with parameters: {'n_features_to_select': 6}.
[I 2019-04-20 19:43:02,694] Finished trial#5 resulted in value: 0.9416543364443433. Current best value is 0.9714318233553381 with parameters: {'n_features_to_select': 6}.
[I 2019-04-20 19:43:04,807] Finished trial#6 resulted in value: 0.9038100386979285. Current best value is 0.9714318233553381 with parameters: {'n_features_to_select': 6}.
[I 2019-04-20 19:43:08,414] Finished trial#7 resulted in value: 0.9231874573184613. Current best value is 0.9714318233553381 with parameters: {'n_features_to_select': 6}.
[I 2019-04-20 19:43:14,815] Finished trial#8 resulted in value: 0.9392783974504895. Current best value is 0.9714318233553381 with parameters: {'n_features_to_select': 6}.
[I 2019-04-20 19:43:19,781] Finished trial#9 resulted in value: 0.9385954928295015. Current best value is 0.9714318233553381 with parameters: {'n_features_to_select': 6}.
[I 2019-04-20 19:43:27,921] Finished trial#10 resulted in value: 0.957503414523105. Current best value is 0.9714318233553381 with parameters: {'n_features_to_select': 6}.
[I 2019-04-20 19:43:37,352] Finished trial#11 resulted in value: 0.7167226269064422. Current best value is 0.9714318233553381 with parameters: {'n_features_to_select': 6}.
[I 2019-04-20 19:43:46,542] Finished trial#12 resulted in value: 0.9767385613475985. Current best value is 0.9767385613475985 with parameters: {'n_features_to_select': 5}.
[I 2019-04-20 19:43:55,495] Finished trial#13 resulted in value: 0.9558815160482587. Current best value is 0.9767385613475985 with parameters: {'n_features_to_select': 5}.
[I 2019-04-20 19:44:03,303] Finished trial#14 resulted in value: 0.9479854313680856. Current best value is 0.9767385613475985 with parameters: {'n_features_to_select': 5}.
[I 2019-04-20 19:44:12,028] Finished trial#15 resulted in value: 0.9614727976325973. Current best value is 0.9767385613475985 with parameters: {'n_features_to_select': 5}.
[I 2019-04-20 19:44:15,797] Finished trial#16 resulted in value: 0.9248235829729115. Current best value is 0.9767385613475985 with parameters: {'n_features_to_select': 5}.
[I 2019-04-20 19:44:16,820] Finished trial#17 resulted in value: 0.9016617345777374. Current best value is 0.9767385613475985 with parameters: {'n_features_to_select': 5}.
[I 2019-04-20 19:44:26,228] Finished trial#18 resulted in value: 0.8863390621443205. Current best value is 0.9767385613475985 with parameters: {'n_features_to_select': 5}.
[I 2019-04-20 19:44:33,167] Finished trial#19 resulted in value: 0.9437030503073071. Current best value is 0.9767385613475985 with parameters: {'n_features_to_select': 5}.
params: {'n_features_to_select': 5}

めでたしめでたし。

Python: IsolationForest で教師なし学習の外れ値検知を試す

今回は教師なし学習で外れ値の検知に使える IsolationForest というアルゴリズムを試してみる。 このアルゴリズムの興味深いところは、教師データの中にある程度外れ値が含まれていても構わないという点。 つまり、アノテーションしていないデータをそのまま突っ込むことが許容されている。

IsolationForest のアルゴリズムでは、決定木を使った分類しやすさにもとづいてデータが正常か外れ値かを判断する。 外れ値は正常なデータに比べると数が少なく、特徴が大きく異なると仮定する。 だとすると、外れ値は正常なデータに比べて分類するのに木の深さがより多く必要と考える。

使った環境は次の通り。

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

下準備

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

$ pip install scikit-learn matplotlib

ダミデータを用意する

IsolationForest を試すのに、以下のようなダミーデータを生成した。 特徴量は二次元で、ひとまず Blob でクラスタが 1 つだけある正常値の周囲に一様分布で外れ値を散らしている。

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

import numpy as np
from sklearn.datasets import make_blobs
from matplotlib import pyplot as plt


def main():
    # 正常値のデータを作る
    NORMAL_DATA_N = 1000
    args = {
        # 正常値のデータ点数
        'n_samples': NORMAL_DATA_N,
        # 特徴量の次元数
        'n_features': 2,
        # クラスタの数
        'centers': 1,
        # データを生成するための乱数
        'random_state': 42,
    }
    X_normal, y_normal = make_blobs(**args)

    # 外れ値のデータを作る
    OUTLIER_DATA_N = 200
    DISTRIBUTION_RANGE = 6
    X_outlier = np.random.uniform(low=-DISTRIBUTION_RANGE,
                                  high=DISTRIBUTION_RANGE,
                                  size=(OUTLIER_DATA_N, 2))
    y_outlier = np.ones((OUTLIER_DATA_N, ))

    # 正常値を中心に外れ値を分布させる
    X_outlier += X_normal.mean(axis=0)

    # くっつけて一つのデータセットにする
    X = np.concatenate([X_normal, X_outlier], axis=0)
    y = np.concatenate([y_normal, y_outlier], axis=0)

    plt.scatter(X[y == 0, 0],
                X[y == 0, 1],
                label='negative')
    plt.scatter(X[y == 1, 0],
                X[y == 1, 1],
                label='positive')
    plt.legend()
    plt.show()


if __name__ == '__main__':
    main()

上記を実行してみる。

$ python blob.py

すると、以下のような散布図が得られる。

f:id:momijiame:20190420122548p:plain

正常値の領域にも外れ値が含まれてしまっているけど、まあ完全に分離可能なデータなんてそうそうないので。

IsolationForest を適用してみる

それでは、先ほどのデータに IsolationForest を適用してみよう。

以下のサンプルコードでは、ホールドアウト検証でどのように分類されるか可視化すると共に評価指標を計算している。 IsolationForest のインスタンスで fit() メソッドを呼ぶときに目的変数 (y_train) に関する情報を渡していないのがポイント。 また、渡している説明変数 (X_train) の中には約 20% の外れ値が含まれている。

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

import numpy as np
from scipy import stats
from sklearn.datasets import make_blobs
from sklearn.ensemble import IsolationForest
from sklearn.model_selection import train_test_split
from sklearn.metrics import precision_score
from sklearn.metrics import recall_score
from sklearn.metrics import f1_score
from sklearn.metrics import matthews_corrcoef
from matplotlib import pyplot as plt


def main():
    # 正常値のデータを作る
    NORMAL_DATA_N = 1000
    args = {
        # 正常値のデータ点数
        'n_samples': NORMAL_DATA_N,
        # 特徴量の次元数
        'n_features': 2,
        # クラスタの数
        'centers': 1,
        # データを生成するための乱数
        'random_state': 42,
    }
    X_normal, y_normal = make_blobs(**args)

    # 外れ値のデータを作る
    OUTLIER_DATA_N = 200
    DIST_RANGE = 6
    X_outlier = np.random.uniform(low=-DIST_RANGE,
                                  high=DIST_RANGE,
                                  size=(OUTLIER_DATA_N, 2))
    y_outlier = np.ones((OUTLIER_DATA_N, ))

    # 正常値を中心に外れ値を分布させる
    X_outlier += X_normal.mean(axis=0)

    # くっつけて一つのデータセットにする
    X = np.concatenate([X_normal, X_outlier], axis=0)
    y = np.concatenate([y_normal, y_outlier], axis=0)

    # ホールドアウト検証するためにデータを分割する
    X_train, X_test, y_train, y_test = train_test_split(X, y,
                                                        test_size=0.3,
                                                        shuffle=True,
                                                        random_state=42,
                                                        stratify=y)

    # IsolationForest
    clf = IsolationForest(n_estimators=100,
                          contamination='auto',
                          behaviour='new',
                          random_state=42)

    # 学習用データを学習させる
    clf.fit(X_train)

    # 検証用データを分類する
    y_pred = clf.predict(X_test)

    # IsolationForest は 正常=1 異常=-1 として結果を返す
    y_pred[y_pred == 1] = 0
    y_pred[y_pred == -1] = 1

    # 評価指標を計算する
    print('precision:', precision_score(y_test, y_pred))
    print('recall:', recall_score(y_test, y_pred))
    print('f1:', f1_score(y_test, y_pred))
    print('mcc:', matthews_corrcoef(y_test, y_pred))

    xx, yy = np.meshgrid(np.linspace(X_normal[:, 0].mean() - DIST_RANGE * 1.2,
                                     X_normal[:, 0].mean() + DIST_RANGE * 1.2,
                                     100),
                         np.linspace(X_normal[:, 1].mean() - DIST_RANGE * 1.2,
                                     X_normal[:, 1].mean() + DIST_RANGE * 1.2,
                                     100))
    Z = clf.decision_function(np.c_[xx.ravel(), yy.ravel()])
    Z = Z.reshape(xx.shape)

    plt.contourf(xx, yy, Z, cmap=plt.cm.Blues_r)
    threshold = stats.scoreatpercentile(y_pred,
                                        100 * (OUTLIER_DATA_N / NORMAL_DATA_N))
    plt.contour(xx, yy, Z, levels=[threshold],
                    linewidths=2, colors='red')

    plt.scatter(X_train[y_train == 0, 0],
                X_train[y_train == 0, 1],
                label='train negative',
                c='b')
    plt.scatter(X_train[y_train == 1, 0],
                X_train[y_train == 1, 1],
                label='train positive',
                c='r')

    plt.scatter(X_test[y_pred == 0, 0],
                X_test[y_pred == 0, 1],
                label='test negative (prediction)',
                c='g')
    plt.scatter(X_test[y_pred == 1, 0],
                X_test[y_pred == 1, 1],
                label='test positive (prediction)',
                c='y')

    plt.legend()
    plt.show()


if __name__ == '__main__':
    main()

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

$ python isoforest.py 
precision: 0.9215686274509803
recall: 0.7833333333333333
f1: 0.8468468468468469
mcc: 0.8229295359450786

同時に、以下のグラフが得られる。 正常値のクラスタの周囲に識別境界ができて、外れ値検出ができていることが分かる。

f:id:momijiame:20190420122902p:plain

正常値のクラスタを増やしてみる

正常値のクラスタが一つのときは、まあなんとなく上手くいきそうなことが分かった。 続いては二つに増やしてみることにしよう。

以下のサンプルコードでは、先ほどの内容を少し修正してダミーデータのクラスタを二つに増やしている。

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

import numpy as np
from scipy import stats
from sklearn.datasets import make_blobs
from sklearn.ensemble import IsolationForest
from sklearn.model_selection import train_test_split
from sklearn.metrics import precision_score
from sklearn.metrics import recall_score
from sklearn.metrics import f1_score
from sklearn.metrics import matthews_corrcoef
from matplotlib import pyplot as plt


def main():
    NORMAL_DATA_N = 1000
    args = {
        'n_samples': NORMAL_DATA_N,
        'n_features': 2,
        # クラスタの数を増やしてみる
        'centers': 2,
        'random_state': 42,
    }
    X_normal, _ = make_blobs(**args)
    y_normal = np.zeros((NORMAL_DATA_N, ))

    OUTLIER_DATA_N = 200
    DIST_RANGE = 6
    X_outlier = np.random.uniform(low=-DIST_RANGE,
                                  high=DIST_RANGE,
                                  size=(OUTLIER_DATA_N, 2))
    y_outlier = np.ones((OUTLIER_DATA_N, ))

    X_outlier += X_normal.mean(axis=0)

    X = np.concatenate([X_normal, X_outlier], axis=0)
    y = np.concatenate([y_normal, y_outlier], axis=0)

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

    clf = IsolationForest(n_estimators=100,
                          contamination='auto',
                          behaviour='new',
                          random_state=42)

    clf.fit(X_train)

    y_pred = clf.predict(X_test)

    y_pred[y_pred == 1] = 0
    y_pred[y_pred == -1] = 1

    print('precision:', precision_score(y_test, y_pred))
    print('recall:', recall_score(y_test, y_pred))
    print('f1:', f1_score(y_test, y_pred))
    print('mcc:', matthews_corrcoef(y_test, y_pred))

    xx, yy = np.meshgrid(np.linspace(X_normal[:, 0].mean() - DIST_RANGE * 1.2,
                                     X_normal[:, 0].mean() + DIST_RANGE * 1.2,
                                     100),
                         np.linspace(X_normal[:, 1].mean() - DIST_RANGE * 1.2,
                                     X_normal[:, 1].mean() + DIST_RANGE * 1.2,
                                     100))
    Z = clf.decision_function(np.c_[xx.ravel(), yy.ravel()])
    Z = Z.reshape(xx.shape)

    plt.contourf(xx, yy, Z, cmap=plt.cm.Blues_r)
    threshold = stats.scoreatpercentile(y_pred,
                                        100 * (OUTLIER_DATA_N / NORMAL_DATA_N))
    plt.contour(xx, yy, Z, levels=[threshold],
                    linewidths=2, colors='red')

    plt.scatter(X_train[y_train == 0, 0],
                X_train[y_train == 0, 1],
                label='train negative',
                c='b')
    plt.scatter(X_train[y_train == 1, 0],
                X_train[y_train == 1, 1],
                label='train positive',
                c='r')

    plt.scatter(X_test[y_pred == 0, 0],
                X_test[y_pred == 0, 1],
                label='test negative (prediction)',
                c='g')
    plt.scatter(X_test[y_pred == 1, 0],
                X_test[y_pred == 1, 1],
                label='test positive (prediction)',
                c='y')

    plt.legend()
    plt.show()


if __name__ == '__main__':
    main()

実行結果は次の通り。

$ python twoclusters.py 
precision: 0.5238095238095238
recall: 0.7333333333333333
f1: 0.611111111111111
mcc: 0.528680532637681

また、以下のグラフが得られる。

f:id:momijiame:20190420123453p:plain

評価指標は悪化しているものの、可視化したものを見るとちゃんと二つのクラスタの周囲に分離境界ができていることがわかる。

いじょう。

Python: scikit-learn の cross_val_predict() 関数で OOF な特徴量を作る

scikit-learn には cross_val_predict() という関数がある。 この関数は、教師データを k-Fold などで分割したときに OOF (Out of Fold) なデータの目的変数を推論する目的で使われることが多い。 なお、OOF (Out of Fold) というのは、k-Fold などでデータを分割した際に学習に使わなかったデータを指している。

scikit-learn.org

ちなみに、この関数は method オプションを指定すると fit() メソッドのあとに呼び出すメソッド名を自由に指定できる。 典型的には、分類問題において predict_proba を指定することで推論結果を確率として取得するために使うことが多い。

ただ、この method オプションには predict 系のメソッドに限らず、別に何を指定しても良いことに気づいた。 そこで、今回の記事では cross_val_predict() 関数を使って OOF な特徴量を作ってみることにした。 というのも、Target Encoding と呼ばれる目的変数を利用した特徴量抽出においては OOF することが Leakage を防ぐ上で必須になる。 もし、その作業を cross_val_predict() 関数と scikit-learn の Transformer を組み合わせることでキレイに書けるとしたら、なかなか便利なのではと思った。

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

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

下準備

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

$ pip install pandas scikit-learn

サンプルコード

以下に cross_val_predict() 関数と Transformer を組み合わせて Target Encoding した特徴量を抽出するサンプルコードを示す。 TargetMeanEncodingTransformer という名前で Target Encoding する Transformer クラスを定義している。 これをそのまま使ってしまうと Target Leakage が起こるため cross_val_predict() 関数と組み合わせることで OOF な特徴量を生成している。 ちなみに cross_val_predict() 関数の返り値は numpy の配列になるため、pandas と組み合わせて使うときはその点に考慮が必要になる。

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

import pandas as pd
from sklearn.base import BaseEstimator
from sklearn.base import TransformerMixin
from sklearn.model_selection import StratifiedKFold
from sklearn.model_selection import cross_val_predict


class TargetMeanEncodingTransformer(BaseEstimator, TransformerMixin):
    """Target mean encoding に使う scikit-learn の Transformer

    NOTE: Target leakage を防ぐために必ず OOF で特徴量を作ること"""

    def __init__(self, category, target=None):
        """
        :param category: エンコード対象のカラム名
        :param target: 目的変数のカラム名
        """
        self.category_ = category
        self.target_ = target
        # 学習したデータを保存する場所
        self.target_mean_ = None

    def fit(self, X, y=None):
        # エンコード対象の特徴量をカテゴリごとにグループ化する
        gdf = X.groupby(self.category_)
        # ターゲットの比率を計算する
        self.target_mean_ = gdf[self.target_].mean()
        # 自身を返す
        return self

    def transform(self, X, copy=None):
        # エンコード対象のカラムを取り出す
        target_series = X[self.category_]
        # エンコードする
        transformed = target_series.map(self.target_mean_.to_dict())
        # 特徴量を返す
        return transformed

    def get_params(self, deep=True):
        # NOTE: cross_val_predict() は Fold ごとに Estimator を作り直す
        #       そのため get_params() でインスタンスのパラメータを得る必要がある
        return {
            'category': self.category_,
            'target': self.target_,
        }


def main():
    # 元データ
    data = [
        ('Apple', 1),
        ('Apple', 1),
        ('Apple', 0),
        ('Banana', 1),
        ('Banana', 0),
        ('Banana', 0),
        ('Cherry', 0),
        ('Cherry', 0),
        ('Cherry', 0),
    ]
    columns = ['category', 'delicious']
    df = pd.DataFrame(data, columns=columns)

    transformer = TargetMeanEncodingTransformer(category='category',
                                                target='delicious')

    # k-Fold しないで適用した場合 (Target leak するのでやっちゃダメ!)
    encoded_without_kfold = transformer.fit_transform(df)
    encoded_without_kfold.name = 'target_mean_encoded'
    print('Without k-Fold')
    concat_df = pd.concat([df, encoded_without_kfold], axis=1, sort=False)
    print(concat_df)

    # k-Fold して適用した場合 (データ点数の関係で実質 LOO になってるので本来はこれもすべきではない)
    skf = StratifiedKFold(n_splits=3,
                          shuffle=True,
                          random_state=42)
    # NOTE: numpy の array として結果が返る点に注意する
    encoded_with_kfold = cross_val_predict(transformer,
                                           # NOTE: エンコード対象のカテゴリで層化抽出する
                                           df, df.category,
                                           cv=skf,
                                           # fit() した後に transform() を呼ぶ
                                           method='transform')
    print('With k-Fold')
    concat_df = pd.concat([df,
                           pd.Series(encoded_with_kfold,
                                     name='target_mean_encoded')
                           ], axis=1, sort=False)
    print(concat_df)


if __name__ == '__main__':
    main()

上記の実行結果は次の通り。 k-Fold ありのパターンでは、ちゃんと OOF で Target Encoding した特徴量が作れているようだ。

$ python oof.py 
Without k-Fold
  category  delicious  target_mean_encoded
0    Apple          1             0.666667
1    Apple          1             0.666667
2    Apple          0             0.666667
3   Banana          1             0.333333
4   Banana          0             0.333333
5   Banana          0             0.333333
6   Cherry          0             0.000000
7   Cherry          0             0.000000
8   Cherry          0             0.000000
With k-Fold
  category  delicious  target_mean_encoded
0    Apple          1                  0.5
1    Apple          1                  0.5
2    Apple          0                  1.0
3   Banana          1                  0.0
4   Banana          0                  0.5
5   Banana          0                  0.5
6   Cherry          0                  0.0
7   Cherry          0                  0.0
8   Cherry          0                  0.0

いじょう。

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

今回は LightGBM で、組み込みで用意されていない独自の評価指標 (カスタムメトリック) を扱う方法について。 ユースケースとしては、学習自体は別の評価指標を使って進めつつ、本来の目標としている評価指標を同時に確認するといったもの。 例えば、精度 (Accuracy) やマシューズ相関係数 (Matthews Correlation Coefficient) は、学習にそのまま用いることは難しい。 しかしながら、最終的な目標としている評価指標がそれらになっていることはよくある。

使った環境は次の通り。

$ sw_vers
ProductName:    Mac OS X
ProductVersion: 10.14.4
BuildVersion:   18E226
$ python -V                  
Python 3.7.3
$ pip list | grep -i lightgbm
lightgbm        2.2.3  

下準備

$ brew install libomp
$ pip install lightgbm scikit-learn matplotlib

独自の評価指標を用いる

以下のサンプルコードでは、学習には LogLoss を使いつつ、同時に Accuracy を計算している。 独自の評価指標を計算するときは train() 関数や cv() 関数で feval というオプションを用いる。 指定するのは評価指標を計算する関数で、引数はモデルが予測した値と学習に使ったデータの二つ。 データからは get_label() という関数で真のラベルを取得できる。 注意点として、モデルが予測した値は多値分類問題であっても一次元の配列になっているため reshape する必要がある。 評価指標を計算する関数では、返り値として評価指標の名前、スコア、そしてスコアが大きい方が優れているのか否かを表す真偽値を返す。

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

import lightgbm as lgb
from sklearn import datasets
from matplotlib import pyplot as plt
import numpy as np
from sklearn.model_selection import train_test_split


def accuracy(preds, data):
    """精度 (Accuracy) を計算する関数"""
    # 正解ラベル
    y_true = data.get_label()
    # 推論の結果が 1 次元の配列になっているので直す
    N_LABELS = 3  # ラベルの数
    reshaped_preds = preds.reshape(N_LABELS, len(preds) // N_LABELS)
    # 最尤と判断したクラスを選ぶ 
    y_pred = np.argmax(reshaped_preds, axis=0)
    # メトリックを計算する
    acc = np.mean(y_true == y_pred)
    # name, result, is_higher_better
    return 'accuracy', acc, True


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

    # 学習用データと検証用データに分割する
    X_train, X_test, y_train, y_test = train_test_split(X, y,
                                                        shuffle=True,
                                                        random_state=42)

    # LightGBM のデータセットの表現に直す
    lgb_train = lgb.Dataset(X_train, y_train)
    lgb_eval = lgb.Dataset(X_test, y_test, reference=lgb_train)

    lgbm_params = {
        'objective': 'multiclass',
        'num_class': 3,
    }

    evals_result = {}
    lgb.train(lgbm_params,
              lgb_train,
              # メトリックを追跡する対象のデータセット
              valid_sets=[lgb_eval, lgb_train],
              # 上記の名前
              valid_names=['eval', 'train'],
              num_boost_round=1000,
              # メトリックの履歴を残すオブジェクト
              evals_result=evals_result,
              # 独自メトリックを計算する関数
              feval=accuracy,
              )

    # 組み込みのメトリック
    eval_metric_logloss = evals_result['eval']['multi_logloss']
    train_metric_logloss = evals_result['train']['multi_logloss']

    # カスタムメトリック
    eval_metric_acc = evals_result['eval']['accuracy']
    train_metric_acc = evals_result['train']['accuracy']

    # グラフにプロットする
    _, ax1 = plt.subplots(figsize=(8, 4))
    ax1.plot(eval_metric_logloss, label='eval logloss', c='r')
    ax1.plot(train_metric_logloss, label='train logloss', c='b')
    ax1.set_ylabel('logloss')
    ax1.set_xlabel('rounds')
    ax1.legend()

    ax2 = ax1.twinx()
    ax2.plot(eval_metric_acc, label='eval accuracy', c='g')
    ax2.plot(train_metric_acc, label='train accuracy', c='y')
    ax2.set_ylabel('accuracy')
    ax2.legend()

    plt.grid()
    plt.show()


if __name__ == '__main__':
    main()

サンプルコードでは、評価指標の推移を折れ線グラフとしてプロットしている。

上記を実行してみよう。 ログを確認すると、ちゃんと Accuracy に関する情報も出力される。

$ python lgbcm.py
...
[1000] train's multi_logloss: 8.23321e-05    train's accuracy: 1 eval's multi_logloss: 0.396726  eval's accuracy: 0.947368

上記を実行すると、以下のようなグラフが得られる。

f:id:momijiame:20190331004325p:plain

上記のグラフを見ると、学習に使った LogLoss に加えて Accuracy の推移も確認できる。 どうやら、イテレーション数が 100 を越えないあたりから検証データに対する LogLoss が増加に転じているようだ。 検証データに対する LogLoss は増加することなく現象し続けており、過学習を起こしていることがわかる。 検証データの LogLoss が増加するタイミングでは、学習データに対する Accuracy が増加すると共に検証データの Accuracy も低下している。

複数のカスタムメトリックを計測したい場合

先ほどの例ではカスタムメトリックが一つだったけど、複数確認したい場合もあるはず。 そのような場合には複数のメトリックをリストとして返す関数を作ることで対応できる。

以下のサンプルコードでは Breast Cancer データセットを用いて Accuracy, Precision, Recall をカスタムメトリックとして計測している。

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

import numpy as np
from sklearn import datasets
from sklearn.metrics import precision_score
from sklearn.metrics import recall_score
from sklearn.model_selection import train_test_split
from matplotlib import pyplot as plt
import lightgbm as lgb


def accuracy(preds, data):
    """精度 (Accuracy) を計算する関数"""
    y_true = data.get_label()
    y_pred = np.where(preds > 0.5, 1, 0)
    metric = np.mean(y_true == y_pred)
    return 'accuracy', metric, True


def precision(preds, data):
    """適合率 (Precision) を計算する関数"""
    y_true = data.get_label()
    y_pred = np.where(preds > 0.5, 1, 0)
    metric = precision_score(y_true, y_pred)
    return 'precision', metric, True


def recall(preds, data):
    """再現率 (Recall) を計算する関数"""
    y_true = data.get_label()
    y_pred = np.where(preds > 0.5, 1, 0)
    metric = recall_score(y_true, y_pred)
    return 'recall', metric, True


def metrics(preds, data):
    """複数の評価指標を計算するための関数"""
    # リストでまとめて返せば良い
    return [
        accuracy(preds, data),
        precision(preds, data),
        recall(preds, data),
    ]


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

    # 学習用データと検証用データに分割する
    X_train, X_test, y_train, y_test = train_test_split(X, y,
                                                        shuffle=True,
                                                        random_state=42)

    # LightGBM のデータセットの表現に直す
    lgb_train = lgb.Dataset(X_train, y_train)
    lgb_eval = lgb.Dataset(X_test, y_test, reference=lgb_train)

    lgbm_params = {
        'objective': 'binary',
        'metric': 'binary_logloss',
    }

    evals_result = {}
    lgb.train(lgbm_params,
              lgb_train,
              num_boost_round=1000,
              early_stopping_rounds=100,
              # メトリックを追跡する対象のデータセット
              valid_sets=[lgb_eval, lgb_train],
              # 上記の名前
              valid_names=['eval', 'train'],
              # メトリックの履歴を残すオブジェクト
              evals_result=evals_result,
              # 独自メトリックを計算する関数
              feval=metrics,
              )

    # 組み込みのメトリック
    eval_metric_logloss = evals_result['eval']['binary_logloss']
    train_metric_logloss = evals_result['train']['binary_logloss']

    # カスタムメトリック (Accuracy)
    eval_metric_acc = evals_result['eval']['accuracy']
    train_metric_acc = evals_result['train']['accuracy']

    # カスタムメトリック (Precision)
    eval_metric_prec = evals_result['eval']['precision']
    train_metric_prec = evals_result['train']['precision']

    # カスタムメトリック (Precision)
    eval_metric_recall = evals_result['eval']['recall']
    train_metric_recall = evals_result['train']['recall']

    # グラフにプロットする
    _, ax1 = plt.subplots(figsize=(8, 4))
    ax1.plot(eval_metric_logloss, label='eval logloss')
    ax1.plot(train_metric_logloss, label='train logloss')
    ax1.set_ylabel('logloss')
    ax1.set_xlabel('rounds')
    ax1.legend()

    ax2 = ax1.twinx()
    ax2.plot(eval_metric_acc, label='eval accuracy')
    ax2.plot(train_metric_acc, label='train accuracy')
    ax2.plot(eval_metric_prec, label='eval precision')
    ax2.plot(train_metric_prec, label='train precision')
    ax2.plot(eval_metric_recall, label='eval recall')
    ax2.plot(train_metric_recall, label='train recall')
    ax2.set_ylabel('ratio')
    ax2.legend()

    plt.grid()
    plt.show()


if __name__ == '__main__':
    main()

上記を実行してみよう。

$ python lgbmcm.py
...
[1]    train's binary_logloss: 0.58296   train's accuracy: 0.629108  train's precision: 0.629108   train's recall: 1   eval's binary_logloss: 0.585825 eval's accuracy: 0.622378   eval's precision: 0.622378  eval's recall: 1

以下のようなグラフが得られる。 たしかに、複数のメトリックが計測できていることが分かる。

f:id:momijiame:20190613060743p:plain

注意点

今回のように複数の評価指標を LightGBM に計算させるときは early_stopping_rounds との併用において注意が必要になる。 というのも early_stopping_rounds で early stopping の対象になるのが「いずれかの評価指標で条件を満たした場合」になっているため。 つまり、本来は LogLoss の値が増加に転じた場合に止めたいのに、まだ現象を続けている段階で別の評価指標が条件を満たすと学習が止まってしまう恐れがある。

Python: LightGBM の cv() 関数から学習済みモデルを得る

(2020-09-05 追記)

LightGBM v3.0.0 から cv() 関数に return_cvbooster オプションが追加されました。 これにより直接 CVBooster のインスタンスが取得できるため、下記のコールバックを使う必要はなくなりました。


勾配ブースティング決定木を扱うフレームワークの一つである LightGBM の Python API には cv() という関数がある。 この "cv" というのは Cross Validation の略で、その名の通り LightGBM のモデルを交差検証するための関数になっている。 具体的には、この関数にデータセットを渡すと、そのデータでモデルを学習させると共に、指定した評価指標について交差検証で評価できる。

今回は、この関数から交差検証の過程で学習させたモデルを手に入れる方法について書いてみる。 というのも、この関数が返すのは指定した評価指標を用いて計測された性能に関する情報に限られているため。 ようするに、交差検証の過程で学習させた学習済みのモデルを手に入れる方法が、標準では用意されていない。 しかしながら、交差検証で性能が確かめられたモデルを取得したい、というニーズはあるはず。

なお、結果から先に書くとコールバック関数を使うことで学習済みモデルを手に入れることができた。

使った環境は次の通り。

$ sw_vers
ProductName:    Mac OS X
ProductVersion: 10.14.4
BuildVersion:   18E226
$ python -V          
Python 3.7.3
$ pip list | grep -i lightgbm
lightgbm        2.2.3  

下準備

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

$ brew install libomp
$ pip install lightgbm scikit-learn

cv() 関数からコールバック関数を使って学習済みモデルを取り出す

論よりコードということで、以下に cv() 関数からコールバック関数を使って学習済みモデルを取り出すサンプルコードを示す。 LightGBM の train() 関数や cv() 関数には、ブースティングのイテレーションごとに呼ばれるコールバック関数が登録できる。 一般的には、コールバック関数は学習の過程などを記録するために用いられる。 しかしながら、コールバック関数にはモデルも渡されるため、今回のような用途にも応用が効く。 内容の細かい解説に関してはコメントの形でコードに含めた。

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

import lightgbm as lgb
from sklearn import datasets
from sklearn.metrics import accuracy_score
from sklearn.model_selection import train_test_split
import numpy as np


class ModelExtractionCallback(object):
    """lightgbm.cv() から学習済みモデルを取り出すためのコールバックに使うクラス

    NOTE: 非公開クラス '_CVBooster' に依存しているため将来的に動かなく恐れがある
    """

    def __init__(self):
        self._model = None

    def __call__(self, env):
        # _CVBooster の参照を保持する
        self._model = env.model

    def _assert_called_cb(self):
        if self._model is None:
            # コールバックが呼ばれていないときは例外にする
            raise RuntimeError('callback has not called yet')

    @property
    def boosters_proxy(self):
        self._assert_called_cb()
        # Booster へのプロキシオブジェクトを返す
        return self._model

    @property
    def raw_boosters(self):
        self._assert_called_cb()
        # Booster のリストを返す
        return self._model.boosters

    @property
    def best_iteration(self):
        self._assert_called_cb()
        # Early stop したときの boosting round を返す
        return self._model.best_iteration


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

    # デモ用にデータセットを分割する
    X_train, X_test, y_train, y_test = train_test_split(X, y,
                                                        shuffle=True,
                                                        random_state=42)

    # LightGBM 用のデータセット表現に直す
    lgb_train = lgb.Dataset(X_train, y_train)

    # 学習済みモデルを取り出すためのコールバックを用意する
    extraction_cb = ModelExtractionCallback()
    callbacks = [
        extraction_cb,
    ]

    # データセットを 5-Fold CV で学習する
    lgbm_params = {
        'objective': 'multiclass',
        'num_class': 3,
    }
    # NOTE: 一般的には返り値の内容 (交差検証の結果) を確認する
    lgb.cv(lgbm_params,
           lgb_train,
           num_boost_round=1000,
           early_stopping_rounds=10,
           nfold=5,
           shuffle=True,
           stratified=True,
           seed=42,
           callbacks=callbacks,
           )

    # コールバックのオブジェクトから学習済みモデルを取り出す
    proxy = extraction_cb.boosters_proxy
    boosters = extraction_cb.raw_boosters
    best_iteration = extraction_cb.best_iteration

    # 各モデルの推論結果を Averaging する場合
    y_pred_proba_list = proxy.predict(X_test,
                                      num_iteration=best_iteration)
    y_pred_proba_avg = np.array(y_pred_proba_list).mean(axis=0)
    y_pred = np.argmax(y_pred_proba_avg, axis=1)
    accuracy = accuracy_score(y_test, y_pred)
    print('Averaging accuracy:', accuracy)

    # 各モデルで個別に推論する場合
    for i, booster in enumerate(boosters):
        y_pred_proba = booster.predict(X_test,
                                       num_iteration=best_iteration)
        y_pred = np.argmax(y_pred_proba, axis=1)
        accuracy = accuracy_score(y_test, y_pred)
        print('Model {0} accuracy: {1}'.format(i, accuracy))

if __name__ == '__main__':
    main()

上記の実行結果は次の通り。 5-Fold CV から得られた全モデルを Averaging する場合と、各モデルごとに推論させた場合の精度 (Accuracy) が示される。

$ python lgbcv.py
...(snip)...
Averaging accuracy: 1.0
Model 0 accuracy: 1.0
Model 1 accuracy: 1.0
Model 2 accuracy: 0.9473684210526315
Model 3 accuracy: 1.0
Model 4 accuracy: 1.0

なお、今回のコードで注意すべきなのは、非公開のクラスを利用しているところがある点。 具体的には ModelExtractionCallback#boosters_proxy から得られるオブジェクトが lightgbm.engine._CVBooster というクラスのインスタンスになっている。 このクラスは、名前の先頭にアンダースコア (_) が含まれることから、外部に API として公開しているものではないと考えられる。 そのため、このモデルのインターフェースが唐突に変わったとしても文句はいえない。

一応、今の _CVBooster がどういったコードになっているのか示しておく。 実装があるのは以下の場所。 このクラスは内部にモデルをリストの形で保持している。 そして、オブジェクトに何らかの呼び出しがあると、特殊メソッドの __getattr__() でトラップされる。 トラップされた呼び出しは、内部のモデルに対して順番に実行されて結果がリストとして返されることになる。

github.com

いじょう。

(2019-04-15 追記)

この記事に掲載したコードをベースに OOF Prediction の実装を追加した Kaggle カーネルをまつけんさん (Twitter: @Kenmatsu4) が公開してくださいました。 ありがとうございます。

www.kaggle.com

Google Cloud SDK の CLI でデフォルトのゾーンやリージョンを設定する

Google Cloud SDK の CLI を使っていると何度も --zone--region といったオプションを指定することになってめんどくさい。 いつも同じ場所を使うのであれば、デフォルトを指定しておくと便利になる。

使った環境は次の通り。

$ sw_vers                                                   
ProductName:    Mac OS X
ProductVersion: 10.14.3
BuildVersion:   18D109
$ gcloud version                             
Google Cloud SDK 239.0.0
bq 2.0.42
core 2019.03.17
gsutil 4.37

デフォルトのゾーンを指定する

デフォルトのゾーンは gcloud config set compute/zone で指定する。

$ gcloud config set compute/zone <ZONE>

例えば東京の C ゾーンを指定してみる。

$ gcloud config set compute/zone asia-northeast1-c

試しにゾーンの指定をしないで Compute Engine のインスタンスを立ち上げてみよう。

$ gcloud compute instances create gce-example \
  --preemptible \
  --machine-type f1-micro \
  --image-project ubuntu-os-cloud \
  --image-family ubuntu-1804-lts

いつもなら、プロンプトでどのゾーンに作る?って聞かれるけど、デフォルトがあると聞かれない。

ちゃんとデフォルトのゾーンでインスタンスが作られる。

$ gcloud compute instances list
NAME         ZONE               MACHINE_TYPE  PREEMPTIBLE  INTERNAL_IP  EXTERNAL_IP   STATUS
gce-example  asia-northeast1-c  f1-micro      true         10.146.0.4   34.85.89.254  RUNNING

デフォルトのリージョンを設定する

同じようにリージョンも設定できる。

$ gcloud config set compute/region <REGION>

例えば東京リージョンを指定するなら、こんな感じ。

$ gcloud config set compute/region asia-northeast1

デフォルトの設定を削除する

デフォルトの設定がいらなくなったときは、次のようにして削除できる。

$ gcloud config unset compute/region
$ gcloud config unset compute/zone

いじょう。