最終更新:2018-04-02
はじめに
スタッキング(stacking)といえば、複数の分類器を組み合わせて強い分類器を作る系の手法である。単なるvotingやsoft votingより強い。
誤解を恐れずにざっくり言ってしまうと、分類器の出力(複数)と真の出力の関係を機械学習する手法である。どうしてそれが強いのかというと、きっと分類器のクセや、出力の相関を機械学習していることが有効なのだろう、という気がする。
「この分類器は基本的にイケてねーんだけど、このラベルだけは正しく分類するんだよな」
「この分類器がこう言ってるときは、こっちのこいつの結果もそれなりに当てになるんだよな」
こういうシチュエーションは色々考えられるので、そういったものに対して効いているのだろう。
とはいえ、具体的にどんな実装になってるのかは割とよくわかってなかったので、実装してみることにした。実装にあたってはこちらを超参考にした。
こちらのサイトでは最終的にskleanライクなライブラリを作成して配布されているので、実用したい方はそちらをインストールされると良いと思う。この記事の趣旨は「(自分が)わかりやすいように書いてみよう」である。
目次
スポンサーリンク
実装編
とりあえずStackingClassifierクラスを作った。sklean風に体裁を整えようとすると、けっこう作らないといけないメソッドが多くて大変なので、__init__とfitとpredictだけ作った。
__init__
__init__は大切である。ここで実装の方針が決まる。
とりあえずコンストラクタで受け取らないといけないものは、
- 初段で使う分類器のリスト
- 初段の分類結果を機械学習するための分類器
の2つがあればなんとかなるかな・・・
1はVotingClassifier*1と同じフォーマットで受け取ることにし、2は単に分類器のインスタンスを受け取ることにした。
あと、実際には初段の分類器は複製して使うので、複製した分類器を保持するデータ構造が必要。あと、必須ではないけど分類器の順番が一意に定まるようにしておいた方が後で変な苦労がない(意味がわからなくても、そのうちわかるので大丈夫)。
ということで、こうなった。
def __init__(self, estimators, merge_estimator): self.original_clfs = dict(estimators) self.m_clf = merge_estimator self.clfs_dict = defaultdict(list) self.clfs_index = sorted(self.original_clfs.keys())
受け取った初段分類器リストは即dictに変換する。ついでに分類器のindexも作る。
fit
stackingのfitでは、
という処理をする必要がある。
クロバリの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では、
- fitのとき作った初段分類器ぜんぶでpredictする
- その出力を二段目に入れて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, merge_estimator): self.original_clfs = dict(estimators) self.m_clf = merge_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.20.3 documentation
*2:クロスバリデーション、交差検証
*3:ただし実際は単なる予測ラベルではなく確率を得た方が有利みたい