CUBE SUGAR CONTAINER

技術系のこと書きます。

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

いじょう。