Titanicから始めよう:GridSearchCVクラスを使ってハイパーパラメーターチューニングしてみた:僕たちのKaggle挑戦記
前回までは基本的にPyTorchで作成したディープニューラルネットワーク(DNN)を作成し、そこにTitanicコンペティションのデータを投入したり、EDAや特徴量エンジニアリング、ハイパーパラメーターチューニングのまねごとをしたりしながら、スコアを向上させようとしてきました。その結果はまずまずなものだと筆者個人は考えています。
筆者の最高スコア「0.78229」は、2022年2月2日時点では2372位と上位20%に入るくらいでした。いいか悪いかと問われたら微妙ではありますが、これ以上のスコアを追い求めるのもそろそろしんどくなってきました。[かわさき]
Titanicコンペは1.0というスコアが出ている人がたくさんいて、これらは答えを入力するなど何らかのズルをしているそうです。また、訓練データも891件と比較的少ないので、ディープラーニングで精度を求めるのも限界がありそうですね。[一色]
今回はscikit-learnと呼ばれる機械学習ライブラリに手を出してみることにします。前回までのスコアに満足したというわけではありませんが、スコアにはそれほどこだわらず、scikit-learnを使ってハイパーパラメーターチューニングをしてみたらどんな感じになるんでしょうか。
なお、今回は前々回に作成し、前回に修正を加えたデータフレームを使用しています(データフレームを加工した手順についてはこちらのノートブックを参照してください)。
今回使用したデータフレーム(ヒートマップ)なお、今回のコードはこちらのノートブックに残してあります。興味のある方はご参照ください。
Titanicコンペティションは乗客の生死を正しく推測することがゴールなわけですが、こうした処理を行うものは「分類器」(classifier)などと呼ばれます。そして、scikit-learnにはそうしたclassifierが多数実装されています。ニューラルネットワークを使用した分類器ももちろんあります。それがsklearn.neural_networkモジュールのMLPClassifierクラスです。頭の「MLP」は「Multi Layer Perceptron」を省略したものです。
このクラスを例に、scikit-learnの分類器の使い方を確認してみましょう。といっても基本的な使い方はとても簡単です。
これだけで学習と推測ができてしまいます。実際、インスタンス生成、学習、推測は(MLPClassifierクラスのインポートと)以下の3行で行えてしまいます。
from sklearn.neural_network import MLPClassifierclf = MLPClassifier(max_iter=500, hidden_layer_sizes=(8,))clf = clf.fit(X_train, y_train)pred = clf.predict(X_val)
MLPClassifierクラスのインスタンスを生成し、学習を行い、生死の推測を行うコードこれまでに自分でクラスを定義して、学習と検証を行うforループを書いて、テストデータを入力するコードを書いて……という手間はどこにいったんだというくらいのカンタンさですね。
上でMLPClassifierクラスのインスタンス生成時にmax_iterとhidden_layer_sizesを指定しているのはデフォルトのままだと学習が終わらなかったからです(もちろん、その前にはCSVファイルの読み込みやデータセットの設定なども行っています。それらについてはこちらのノートブックを参照してください)。
隠れ層のノード数とか、最適化アルゴリズムの選択とか、バッチサイズはどうなっているの? というと、そうしたパラメーター(ハイパーパラメーター)はインスタンス生成時に指定できます。MLPClassifierクラスであれば、以下のようなものが指定可能です(一部)。
scikit-learnでイテレーション(iteration)という用語は、エポック(epoch)と同じ意味で使われているようですね。ディープラーニングだと一般的には、イテレーションは重みやバイアスを更新するバッチサイズのデータごとの回数を指し、エポックは全データが使用される回数を指すので、別の意味になります。つまり1回のエポックの中に、複数回のイテレーションがあるという関係です。と思ってヘルプドキュメントを見たら言及されていました。
ありがとうございます。こういうところには気を付けないといけませんね。
ハイパーパラメーターチューニングではこれらの値(の幾つか)がどんな値を取るのか、その候補を指定して、実際のチューニングを行うクラスのインスタンスメソッドを呼び出すことになります。
上で見た学習と推測はfitメソッドを呼び出して、次にpredictメソッドを呼び出すというパターンは他の分類器についても同様です。そのため、次のような関数を定義して、
def fit_and_pred(clf, X_train, X_val, y_train, y_val):clf = clf.fit(X_train, y_train)pred = clf.predict(X_val)cnt = len(pred)result = sum(pred == y_val)clf_name = clf.__class__.__name__print(f'{clf_name}: {result} / {len(y_val)} = {result / len(y_val):.4}')
学習と推測(検証)を行う関数分類(ここでは2クラス分類)に使えるクラスのインスタンスを以下のようにして用意し、
clfs = [DecisionTreeClassifier(random_state=0),# 決定木RandomForestClassifier(random_state=0),# ランダムフォレスト分類器KNeighborsClassifier(),# K近傍法SVC(random_state=0),# サポートベクタマシンによる分類MLPClassifier(max_iter=2000, random_state=0)# 多層パーセプトロン]
分類器のインスタンスを含むリスト以下のforループで簡単に学習と推測が行えます。
for clf in clfs:fit_and_pred(clf, X_train, X_val, y_train, y_val)
学習と推測を行うループというわけで、特にハイパーパラメーターをチューンしていない状態でどんな結果が出るか、上のコードを実行した結果を以下に示します。
実行結果まずまずの結果が得られたように思えます。次に学習が終わったモデルを使ってテストデータから生死の推測を行い、サブミッションしてみましょう。ここでは上で生成した5つのインスタンス全てで推測を行い、その結果、3つ以上のモデルが1(生存)と推測したものは最終的な推測結果は1に、そうでなければ0としました。
preds = [clf.predict(df_test) for clf in clfs]result = sum(preds)result[result <= 2] = 0result[result > 2] = 1print(f'{sum(result)} / {len(result)} = {sum(result) / len(result)}')submission = pd.read_csv('../input/titanic/test.csv')submission['Survived'] = resultdrop_columns = ['Pclass', 'Name', 'Sex', 'Age', 'SibSp', 'Parch', 'Ticket','Fare', 'Cabin', 'Embarked']submission = submission.drop(drop_columns, axis=1)submission.to_csv('submission_sklearn.csv', index=False)
テストデータから5つのモデルを使って生死の推測を行うちなみにこのスコアは0.75837でした。前回よりも低いスコアです。ホッとする一方で、もっとやれるだろーと思わないでもないですね。
それでは次に、scikit-learnが提供するGridSearchCVクラスを使って、上記のモデルのハイパーパラメーターをチューンしてみましょう。その基本的な手順は以下の通りです。
例えば、先ほど見たMLPClassifierクラスを例に取ると、最初の2つ(インスタンス生成とハイパーパラメーターの候補の記述)は次のようなコードになります。
clf4 = MLPClassifier()params4 = {'max_iter': [2000],'hidden_layer_sizes': [(16,), (32,), (64,), (128,), (256,)],'random_state': [0]}
インスタンス生成とハイパーパラメーターの値の候補(その1)ハイパーパラメーターの名前をキーとして、その候補をリストにまとめたものを値として辞書に登録している点に注目してください。
なお、上ではmax_iterとrandom_stateの値の候補が1つしかないので、上記コードは以下のように書いてもよかったかもしれません(実際、これでも動きました)。
clf4 = MLPClassifier(max_iter=2000, random_state=0)params4 = {'hidden_layer_sizes': [(16,), (32,), (64,), (128,), (256,)],}
インスタンス生成とハイパーパラメーターの値の候補(その2)これらを使ってGridSearchCVクラスのインスタンスを生成して、チューンを行い、ベストスコアとそのときのハイパーパラメーターの値を表示するコードは以下の通りです。
from sklearn.model_selection import GridSearchCVgscv = GridSearchCV(clf4, params4, scoring='accuracy', cv=5)result = gscv.fit(X, y)print(result.best_score_)print(result.best_params_)
ハイパーパラメーターをチューンGridSearchCVクラスの「CV」は「Cross Validation」(交差検証)を意味しています(多分)。つまり、fitメソッドを呼び出すだけで交差検証を自動的にしてくれるということです。このときに元データを何個に分割するかをcv引数に指定します。scoringは評価の指標を何にするかですが、ここでは'accuracy'(正解数/全要素数)を指定しています(指定可能な値についてはscikit-learnのドキュメント「The scoring parameter: defining model evaluation rules」を参照のこと)。
また、GridSearchCVクラスでは交差検証を自動的にしてくれるので、ここではCSVから読み込んだ全てのデータをそのままfitメソッドに渡すようにしました(XとyはCSVファイルから読み込んだデータから不要な列を削除して、訓練データと教師データに分けただけのもので、train_test_split関数で分割はしていません)。
今見た手順を先ほど見た5つの分類器クラスに対して行えばよいので、まずはインスタンスとハイパーパラメーターの候補を以下のように記述しました。
clf0 = DecisionTreeClassifier()params0 = {'criterion': ['gini', 'entropy'],'max_depth': list(range(3, 10)),'min_samples_split': list(range(2, 5)),'min_samples_leaf': list(range(1, 4)),'random_state': [0]}clf1 = RandomForestClassifier()params1 = {'n_estimators': [10, 50, 100, 300, 500],'max_depth': [5, 10, 50, None],'max_features': ['sqrt', 'log2'],'random_state': [0]}clf2 = KNeighborsClassifier()params2 = {'n_neighbors': list(range(3, 15)),'weights': ['uniform', 'distance'],'metric': ['minkowski', 'euclidean','manhattan'],'p': [1, 2]}clf3 = SVC()params3 = {'kernel': ['linear', 'poly', 'rbf', 'sigmoid'],'C': [1, 5, 10, 30],'gamma': ['auto', 'scale'],'random_state': [0]}clf4 = MLPClassifier(max_iter=2000, random_state=0)params4 = {#'max_iter': [2000],'hidden_layer_sizes': [(16,), (32,), (64,), (128,), (256,)],#'random_state': [0]}
分類器のインスタンス生成とハイパーパラメーターの候補の設定なお、各分類器のハイパーパラメーターの候補については、筆者がドキュメントを見ながら適当に選んだものです。この辺は各分類器についての深い理解が必要になるようですが、筆者自身がまだ勉強中ということもあり、選択が適切かどうか自信があるかと問われたら……。あくまでもscikit-learnでハイパーパラメーターチューニングをしてみた程度のサンプルだと思ってください。
これらを使ってハイパーパラメーターをチューンするコードが以下です。
clfs = [clf0, clf1, clf2, clf3, clf4]search_params = [params0, params1, params2, params3, params4]results = []for clf, params in zip(clfs, search_params):gscv = GridSearchCV(clf, params, scoring='accuracy', cv=5)result = gscv.fit(X, y)clf_name = clf.__class__.__name__print(f'result of {clf_name}')print(f'score: {result.best_score_}')print(f'best params: {result.best_params_}')print('----')results.append(result)
ハイパーパラメーターをチューンするコードfitメソッドはチューンにより自身を更新し、それを戻り値とします。GridSearchCVクラスのインスタンスにはbest_score_やbest_params_などの属性があるので、ここではベストスコアとそのときのハイパーパラメーターの値を表示するようにしています(最後にアンダースコア「_」がある点に注意)。加えて、best_estimator_属性にはベストスコアを得たモデルが格納されているので、これを使ってテストデータから生死の推測をするために、resultsに戻り値を保存するようにしました。
実行結果を以下に示します。
実行結果むむむ。チューニングによって、スコアが上がったものもあれば、同じか下がったものもあるようです。
これはチューニング対象のハイパーパラメーターの選択や、fitメソッドでの学習における差異などが原因かもしれません。
何か釈然としませんが、ベストスコアを得たモデルを使って、テストデータから生死を推測してみましょう。
preds = [item.best_estimator_.predict(df_test) for item in results]result = sum(preds)result[result <= 2] = 0result[result > 2] = 1print(f'{sum(result)} / {len(result)} = {sum(result) / len(result)}')submission = pd.read_csv('../input/titanic/test.csv')submission['Survived'] = resultdrop_columns = ['Pclass', 'Name', 'Sex', 'Age', 'SibSp', 'Parch', 'Ticket','Fare', 'Cabin', 'Embarked']submission = submission.drop(drop_columns, axis=1)submission.to_csv('submission_gridsearch.csv', index=False)
ベストスコアを得たモデルを使って、テストデータから生死を推測fitメソッドの戻り値のbest_estimator_属性(ベストスコアを得た分類器のインスタンス)を使って生死を推測し、それをサブミット用にCSVファイルに保存しています。なお、チューン後のスコアは0.76555(チューン前のスコアは0.75837)。取りあえずチューンによってスコアが上昇したことは喜ばしいことですね。
スコアが上がって良かったです。ハイパーパラメーターのチューニングで逆に下がったのはなぜなのでしょうね……。ハイパーパラメーターの候補の組み合わせパターンによってはデフォルト値の組み合わせよりも悪くなりやすいなどがあるのかもしれないと思いました。
あとは、時間がかかってしまいますが、ハイパーパラメーターの候補をもっと増やすとよいのかもしれません。僕が手動でチューンするときは、候補をいろいろと変えて何度もチューンを繰り返していき、良さそうな候補の組み合わせが見付かってから、さらに0.1単位から0.01単位、0.001単位と徐々に狭めながらより細かい値でチューンしたりしました。しかし時間はかかりますね……。僕が知っているハイパーパラメーター自動最適化フレームワークの中だと、Optunaはグリッド(表形式)で指定した固定の候補ではなく範囲の候補で指定できるので、より効率的に細かい値でチューンしやすかったです。
そうですね。時間はかかりますが、もっとよい値は見つかるんじゃないかと思いました。Optunaかー。今度調べてみます。
今回はscikit-learnを使って、分類器の使い方とハイパーパラメーターチューニングの方法をざっくりと見てみました。PyTorchを使っていろいろなコードを自分で記述するのは自分が何をしているのかを把握でき、コード記述の楽しさも味わえます。しかし、scikit-learnを使うことで複雑なことをシンプルなコードで実現できることは大きな魅力です。
というわけで、Titanicコンペティションから始まったKaggleライフですが、最初は何も考えずにDNNにデータを突っ込んでみてあまりよくないスコアを出してみたり、EDAや特徴量エンジニアリング、ハイパーパラメーターチューニングのまねごとをしてみたりしてきました。実感したのは、「前処理」は大事ということです。「garbage in, garbage out」(ゴミを入れると、ゴミが出てくる)というのはプログラミングの世界においては変わらぬ真理のようです。
Titanicコンペティションに参加しながら、データとの対話やハイパーパラメーターチューニングを通して何をどうすればよい結果が得られるのか、ほんのちょっとした手がかりを得られたような気がします。次はTitanicコンペティションから離れてまた別のコンペティションにのんびりと参加しながら、経験値とスコアを少しずつ向上させていくことにしましょう。