静かなる名辞

pythonとプログラミングのこと



【python】スタッキング(stacking)分類器を実装して理解する

 最終更新:2018-04-02

はじめに

 スタッキング(stacking)といえば、複数の分類器を組み合わせて強い分類器を作る系の手法である。単なるvotingやsoft votingより強い。

 誤解を恐れずにざっくり言ってしまうと、分類器の出力(複数)と真の出力の関係を機械学習する手法である。どうしてそれが強いのかというと、きっと分類器のクセや、出力の相関を機械学習していることが有効なのだろう、という気がする。

「この分類器は基本的にイケてねーんだけど、このラベルだけは正しく分類するんだよな」
「この分類器がこう言ってるときは、こっちのこいつの結果もそれなりに当てになるんだよな」

 こういうシチュエーションは色々考えられるので、そういったものに対して効いているのだろう。

 とはいえ、具体的にどんな実装になってるのかは割とよくわかってなかったので、実装してみることにした。実装にあたってはこちらを超参考にした。

segafreder.hatenablog.com

 こちらのサイトでは最終的にskleanライクなライブラリを作成して配布されているので、実用したい方はそちらをインストールされると良いと思う。この記事の趣旨は「(自分が)わかりやすいように書いてみよう」である。

 目次

実装編

 とりあえずStackingClassifierクラスを作った。sklean風に体裁を整えようとすると、けっこう作らないといけないメソッドが多くて大変なので、__init__とfitとpredictだけ作った。

__init__

 __init__は大切である。ここで実装の方針が決まる。

 とりあえずコンストラクタで受け取らないといけないものは、

  1. 初段で使う分類器のリスト
  2. 初段の分類結果を機械学習するための分類器

 の2つがあればなんとかなるかな・・・

 1はVotingClassifier*1と同じフォーマットで受け取ることにし、2は単に分類器のインスタンスを受け取ることにした。

 あと、実際には初段の分類器は複製して使うので、複製した分類器を保持するデータ構造が必要。あと、必須ではないけど分類器の順番が一意に定まるようにしておいた方が後で変な苦労がない(意味がわからなくても、そのうちわかるので大丈夫)。

 ということで、こうなった。

def __init__(self, estimators, marge_estimator):
    self.original_clfs = dict(estimators)
    self.m_clf = marge_estimator

    self.clfs_dict = defaultdict(list)
    self.clfs_index = sorted(self.original_clfs.keys())

 受け取った初段分類器リストは即dictに変換する。ついでに分類器のindexも作る。

fit

 stackingのfitでは、

  1. クロバリ*2して全データに対して予測ラベルを得る*3
  2. 1を初段で使う分類器すべてで行う(学習した分類器は保存しておく。predictするとき使うので)
  3. 2の出力を特徴量にして二段目を学習させる

 という処理をする必要がある。

 クロバリのk-foldのkをいくつにするかで、葛藤があるといえばある。性能を考えるとleave-one-outがおそらく理想だが、そのぶんリソースがかさむ訳で。とりあえず今回は15で決め打ちにする。

 以下、コードと説明を交互に示しながら解説。

 まず、クロバリ用のインデックスのリストを先に作っておく。

def fit(self, X, y):
    self.clfs_dict = defaultdict(list) # fitする度にリセットしないといけないので初期化する

    skf = StratifiedKFold(n_splits=15)
    index_list = list(skf.split(X, y))

 
 そして謎のforループで一足とびに初段の学習を終える。

    merge_feature_list = []
    for clf_name in self.clfs_index:
        clf_origin = self.original_clfs[clf_name]
        preds_tmp_list = []
        for train_index, test_index in index_list:
            clf_copy = deepcopy(clf_origin)
            clf_copy.fit(X[train_index], y[train_index])
            preds_tmp_list.append(
                clf_copy.predict_proba(X[test_index]))
            self.clfs_dict[clf_name].append(clf_copy)
        merge_feature_list.append(np.vstack(preds_tmp_list))

 更に、二段目もあっさり学習させる。

    X_merged = np.hstack(merge_feature_list)
    y_merged = np.hstack([y[test_index] 
                          for _, test_index in index_list])
    self.m_clf.fit(X_merged, y_merged)

    return self

 解説になってねーな・・・。

 説明が極めて困難だが、難しい処理はしていない(つもり)。たぶん謎のforループが読みづらいと思うが、この節の頭に書いた箇条書きの、1の処理をvstackでベクトルを縦に積んでいくことで達成し、2の処理をhstackで横に並べて実現していると考えれば難しくない・・・だろう。あとは分類器コピーしてfitさせて保存してるだけ。

 このコードは見た目もキモいが、書くのはもっと大変だった。二重forループだけど、どっちのforを外に出そうか・・・とか悩んだりして手間取った。あと、クロバリのindexがおかしなことにならないように管理しなきゃいけないとか、注意することがそれなりにあった。

predict

 stackingのpredictでは、

  1. fitのとき作った初段分類器ぜんぶでpredictする
  2. その出力を二段目に入れてpredictする

 という処理を基本的には行う。ただし、上では明示的に説明しなかったが、初段分類器は分類器の種類ごとにクロバリした回数分だけできているので、それをどうまとめるかが若干問題になる気がする。参考サイトによると、単にぜんぶに入力して結果を平均すれば良いらしいが。

 predictの処理はfitよりは書きやすかった。

def predict(self, X):
    merge_feature_list = []
    for clf_name in self.clfs_index:
        tmp_proba_list = []
        for clf in self.clfs_dict[clf_name]:
            tmp_proba_list.append(clf.predict_proba(X))
        merge_feature_list.append(
            np.mean(tmp_proba_list, axis=0))
    X_merged = np.hstack(merge_feature_list)
    return self.m_clf.predict(X_merged)

 np.meanに3次元配列を渡してaxis=0を指定すると配列の平均が作れる、ということを初めて知った。

まとめ編

 全体のプログラムを次のリストに示す。
stacking.py

# coding: UTF-8

from copy import deepcopy
from collections import defaultdict

import numpy as np
from sklearn.model_selection import StratifiedKFold

class StackingClassifier:
    def __init__(self, estimators, marge_estimator):
        self.original_clfs = dict(estimators)
        self.m_clf = marge_estimator

        self.clfs_dict = defaultdict(list)
        self.clfs_index = sorted(self.original_clfs.keys())

    def fit(self, X, y):
        self.clfs_dict = defaultdict(list)

        skf = StratifiedKFold(n_splits=15)
        index_list = list(skf.split(X, y))

        merge_feature_list = []
        for clf_name in self.clfs_index:
            clf_origin = self.original_clfs[clf_name]
            preds_tmp_list = []
            for train_index, test_index in index_list:
                clf_copy = deepcopy(clf_origin)
                clf_copy.fit(X[train_index], y[train_index])
                preds_tmp_list.append(
                    clf_copy.predict_proba(X[test_index]))
                self.clfs_dict[clf_name].append(clf_copy)
            merge_feature_list.append(np.vstack(preds_tmp_list))
        
        X_merged = np.hstack(merge_feature_list)
        y_merged = np.hstack([y[test_index] 
                              for _, test_index in index_list])

        self.m_clf.fit(X_merged, y_merged)
        return self

        
    def predict(self, X):
        merge_feature_list = []
        for clf_name in self.clfs_index:
            tmp_proba_list = []
            for clf in self.clfs_dict[clf_name]:
                tmp_proba_list.append(clf.predict_proba(X))
            merge_feature_list.append(
                np.mean(tmp_proba_list, axis=0))
        X_merged = np.hstack(merge_feature_list)

        return self.m_clf.predict(X_merged)

 コード総量50行弱、importとか空行端折ったら40行切るくらいの簡単なプログラムだが、stackingを実装している。

ベンチマーク

 性能を確認するために、昨日の記事で使ったプログラムにstackingを追加し、単体の分類器やvotingと比較してみる。

# coding: UTF-8

import numpy as np

from sklearn.svm import SVC
from sklearn.linear_model import LogisticRegression
from sklearn.neighbors import KNeighborsClassifier as KNN
from sklearn.naive_bayes import GaussianNB as GNB
from sklearn.ensemble import RandomForestClassifier as RFC
from sklearn.ensemble import BaggingClassifier
from sklearn.neural_network import MLPClassifier
from sklearn.ensemble import VotingClassifier

from sklearn.datasets import load_digits
from sklearn.model_selection import train_test_split
from sklearn.metrics import precision_recall_fscore_support

from stacking import StackingClassifier

def main():
    digits = load_digits()
    noised_data = digits.data + np.random.random(digits.data.shape)*15

    X_train, X_test, y_train, y_test = train_test_split(
        noised_data, digits.target, test_size=0.8)

    svm =SVC(C=5, gamma=0.001, probability=True)
    lr = LogisticRegression()
    knn = KNN(n_jobs=-1)
    nb = GNB()
    rfc = RFC(n_estimators=500, n_jobs=-1)
    bgg = BaggingClassifier(n_estimators=300, n_jobs=-1)
    mlp = MLPClassifier(hidden_layer_sizes=(40, 20), max_iter=1000)

    estimators = list(zip(["svm","lr","knn","nb","rfc","bgg","mlp"],
                          [svm,lr,knn,nb,rfc,bgg,mlp]))
    for name, clf in estimators:
        clf.fit(X_train, y_train)
        preds = clf.predict(X_test)
        print(name)
        print("p:{0:.4f} r:{1:.4f} f1:{2:.4f}".format(
            *precision_recall_fscore_support(y_test, preds, average="macro")))

    for v in ["hard", "soft"]:
        vc_hard = VotingClassifier(estimators, voting=v)
        vc_hard.fit(X_train, y_train)
        preds = vc_hard.predict(X_test)
        print(v, "voting")
        print("p:{0:.4f} r:{1:.4f} f1:{2:.4f}".format(
            *precision_recall_fscore_support(y_test, preds, average="macro")))

    # ここから先だけ追加した
    stcl = StackingClassifier(estimators, RFC(n_estimators=2000, n_jobs=-1))
    stcl.fit(X_train, y_train)
    preds = stcl.predict(X_test)
    print("stacking")
    print("p:{0:.4f} r:{1:.4f} f1:{2:.4f}".format(
        *precision_recall_fscore_support(y_test, preds, average="macro")))
    
if __name__ == "__main__":
    main()

 二段目の分類器を何にするかは検討の余地があるが、とりあえず細かいパラメタチューニングをしなくても性能が出るRandomForestを突っ込んでみた(ランダムフォレスト厨)。ここに関してはMLPにするとか、ロジスティック回帰みたいな単純なものにするとか、もう一段スタッキング分類器を重ねるとか、色々な流派があるっぽい。

 結果

svm
p:0.8662 r:0.8562 f1:0.8531
lr
p:0.7487 r:0.7429 f1:0.7422
knn
p:0.8340 r:0.8287 f1:0.8212
nb
p:0.8304 r:0.8250 f1:0.8243
rfc
p:0.8470 r:0.8429 f1:0.8384
bgg
p:0.7809 r:0.7834 f1:0.7770
mlp
p:0.6668 r:0.6625 f1:0.6625
hard voting
p:0.8690 r:0.8626 f1:0.8594
soft voting
p:0.8589 r:0.8557 f1:0.8546
stacking
p:0.8784 r:0.8733 f1:0.8724

 votingに対して2%弱の改善か。巷で言われている効果もだいたいそんな感じなので、たぶん妥当な結果なんだろう。

*1:sklearn.ensemble.VotingClassifier — scikit-learn 0.19.1 documentation

*2:クロスバリデーション、交差検証

*3:ただし実際は単なる予測ラベルではなく確率を得た方が有利みたい