SIGNATEコンペ　PCゲームの勝敗予測

先日（というか昨日までか）、「PCゲームの勝敗予測」というSIGNATEのビギナー向けコンペに参加してみました。
結果でいうと最終的に11位/298名という順位だったのですが、コンペにあたって工夫した点や反省すべき点なんかを復習がてら振り返って見たいと思います。

# コンペの概要
SIGNATEが毎月開催している（らしい）ビギナー向けのコンペの一つです。

https://signate.jp/competitions/356
今回は2021年1月12日〜1月31日の3週間弱開かれていたようです。

詳細はリンクの概要を見ていただた方が良いのですが、ざっくりいうと「チーム同士の対戦が行われるPCゲームにおいて、キル数や非キル数、獲得した経験値などを特徴量に勝敗を予測する」二値分類のタスクです。
ぼくあんまりこのてのPCゲームやらないんでよく分からないんですけど、経験値があるってことはlolみたいなゲームを想定しているんですかね？
詳しい人いたら教えてもらえると嬉しいです。

### 対象データ
train.csv、test.csv、sample_submit_id.csvのデータが用意されています。
train.csvを読み込むとこんな感じ。
![スクリーンショット 2021-02-01 21.38.09.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1073313/f6d9bed1-ce32-e9bc-bd1d-974f007baf9a.png)

データの件数はtrainが8000件、testが2000件となっています。
データは全て整数で、欠損値もありませんでした。
blueKiss、blueDeath等の変数は分かりやすいですが、blueEliteMonstersとかblueEliteDragonsとか何それって感じでした。
特定の強いモンスターを倒した数みたいですね。へー。

また二値分類のタスクということで1と0の偏りが心配だったのですが、綺麗にほぼ1:1となっていました。
サンプリング等の事前準備は不要ですね。

ちなみにデータは「試合の最初の10分間」の情報らしいです。

### 評価指標
Accuracyです。分かりやすいですね。


# データの準備
本来ならModelの作成前に欠損値の補正やラベルエンコーディング等の事前処理、またEDAをすべきなのですが終了までにあまり時間がなかったのでModel作ってSubmitすることを優先しました……。
一応今回のデータは非常にキレイなものだったので（さすがビギナー向けコンペ）、素の状態でもそれなりに動くものができます。


# Model作成その①　LightGBM
まずは初手LightGBMで攻めて見ます。
細かなパラメーターチューニングは置いておいて、クロスバリデーションで分割したデータを元に分割した分だけ学習を行います。
今回は4つのFoldをとりましたので学習が完了すると4つのスコアと4つのModelが得られることになります。
この4Modelでそれぞれ予測を行い、その平均を最終的な予測値としてSubmitします。

```python:LightGBM
# lightGBMのModelを構築

import lightgbm as lgb
from sklearn.metrics import accuracy_score
from sklearn.model_selection import KFold


# modelのパラメーター
params = {
    'task' : 'train',
    'boosting_type' : 'gbdt',
    'objective' : 'binary',
    'seed' : 71,
    'verbose' : 0,
    'metric' : 'binary-logloss'
}

# スコア、モデル保存用の配列
scores = []
models = []

# 訓練データをK-Foldにより4分割
kf = KFold(n_splits=4, shuffle=True, random_state=71)

# 学習を実施
for tr_idx, va_idx in kf.split(X_train):
  # 学習データ、評価データに分割
  tr_x, va_x = X_train.iloc[tr_idx], X_train.iloc[va_idx]
  tr_y, va_y = y_train.iloc[tr_idx], y_train.iloc[va_idx]
 
  # lightGBMデータ構造に変換
  lgb_train = lgb.Dataset(tr_x, tr_y)
  lgb_eval = lgb.Dataset(va_x, va_y, reference=lgb_train)

  model_gbm = lgb.train(
      params,
      lgb_train,
      num_boost_round=500,
      valid_sets=lgb_eval
  )

  # スコアの確認
  pred_y = model_gbm.predict(va_x)
  pred_y_label = np.where(pred_y>0.5, 1, 0)
  score = accuracy_score(pred_y_label, va_y)

  # 結果を格納
  scores.append(score)
  models.append(model_gbm)

# 予測実行関数
def pred(models, X_test):
  # 予測結果サマリ
  pred_y_summary = []

  # model分ループ
  for i in range(len(models)):
    # 予測を実行
    pred_y = models[i].predict(X_test)
    # 結果を格納
    pred_y_summary.append(pred_y)
  
  # 各モデルの予測結果の平均値を作成
  pred_y_mean = np.mean(pred_y_summary, axis=0)
  return pred_y_mean

# 予測を実行（Mean）
pred_y = pred(models, test)
pred_y_label = np.where(pred_y>0.5, 1, 0)
```

学習の結果、それぞれのModelのスコアは[0.7805, 0.776, 0.774, 0.773]という結果でした。
最終的な予測値pred_y_labelをSubmitしたところ、Leaderboard上は0.7965というスコアが得られています。
コンペ終了後のLeaderboardでいうと41〜45位の間です（Submitしたタイミングによって順位は変動します）。

ちなみにこのビギナー向けコンペでは一定以上のスコアをSubmitできた時点でBeginnerからIntermidiateへ昇格することができます。
今回はその閾値が0.75だったので、とりあえず第一目標はクリアですね。


# Model作成その②　LightGBMパラメーターチューニング
もう少しLightGBMを深掘りします。
せっかくなんでハイパーパラメーターのチューニングを試してみましょう。

チューニングの方法は色々あると思いますが、ひとまずはoputunaを利用します。
実装上はLightGBMを直接importするのではなく、oputunaを通してimportするだけでOKです。

```python:LightGBM(oputuna)
# チューニングあり
from optuna.integration import lightgbm as lgb

model_gbm_tuned = lgb.train(param, lgb_train, valid_sets=lgb_eval, num_boost_round=1000, early_stopping_rounds=50)

# スコアの確認
pred_y = model_gbm_tuned.predict(va_x)
pred_y_label = np.where(pred_y>0.5, 1, 0)
```

学習の結果、0.799というスコアが得られました。
ちなみにこのModelで予測した結果はSubmitできていないのでLeaderboard上のスコアは分かりません……一日の投稿上限回数速攻で使い切ってしまったのです……。

なお、チューニング後のパラメーターは.paramsから確認することができます。

```python
# パラメーターを確認
model_gbm_tuned.params

{'bagging_fraction': 0.4600347572555584,
 'bagging_freq': 5,
 'boosting_type': 'gbdt',
 'feature_fraction': 0.7,
 'feature_pre_filter': False,
 'lambda_l1': 0.004418000666138604,
 'lambda_l2': 8.039538280454251e-06,
 'min_child_samples': 20,
 'num_leaves': 4,
 'objective': 'binary',
 'seed': 71,
 'task': 'train',
 'verbose': 0}
```

# Model作成その③　他のアルゴリズムの検討
ひとまずLightGBMのModelでそこそこの（？）スコアが得られることは分かったので、じゃあ他のアルゴリズムではどうなんだ？　ってことで色々と試してみます。

```python
from sklearn.linear_model import LogisticRegression
from sklearn.neighbors import KNeighborsClassifier
from sklearn.tree import DecisionTreeClassifier
from sklearn.ensemble import RandomForestClassifier
from sklearn.ensemble import AdaBoostClassifier
from sklearn.naive_bayes import GaussianNB

names = ['LogisticRegression', 'NearestNeighbors', 'RandomForest', 'DecisionTree','AdaBoost', 'NaiveBayes']

classifiers = [
    LogisticRegression(random_state=123),
    KNeighborsClassifier(),
    RandomForestClassifier(random_state=123),
    DecisionTreeClassifier(random_state=123),
    AdaBoostClassifier(random_state=123),
    GaussianNB()]

for name, model in zip(names, classifiers):
  model.fit(tr_x, tr_y)
  pred_y = model.predict(va_x)
  score = accuracy_score(pred_y, va_y)
  print(name, ' Accuracy : ', score)
```

各Modelのスコアは以下の結果となりました。

| Model             | スコア   |
|:------------------|:-------|
| LogisticRegression| 0.715 |
| NearestNeighbors  | 0.744  |
| RandomForest      | 0.757  |
| DecisionTree      | 0.716  |
| AdaBoost          | 0.761  |
| NaiveBayes        | 0.6895 |

RandomForestとAdaBoostが良さそうですね。
ここから先はModelのアンサンブルを考えて行きたいのですが、AdaBoostの方はLightGBMと相関が高くこれをアンサンブルさせてもあまりスコアの向上は見込めなさそうです。
対してRandomForestは（Adaと比べれば割と）相関が低めだったので、アンサンブルはこちらを採択することにします。

ちなみにRandomForestについてもグリッドサーチでチューニングを行いました。

```python
# lightGBMのModelを構築

from tqdm import tqdm
from sklearn.metrics import accuracy_score
from sklearn.ensemble import RandomForestClassifier
from sklearn.model_selection import KFold
from sklearn.model_selection import GridSearchCV

# グリッドサーチの条件設定
grid = {RandomForestClassifier(random_state=123): {'n_estimators' : [i for i in range(1, 30)],
                                                      'criterion' : ['gini', 'entropy'],
                                                      'max_depth' : [i for i in range(1, 10)]}}

# ベストスコア
best_score = 0

# 予測
for model, param in tqdm(grid.items()):
  # Model構築
  clf = GridSearchCV(model, param)
  clf.fit(tr_x, tr_y)
  # スコアの確認
  pred_y = clf.predict(va_x)
  score = accuracy_score(pred_y, va_y)
  # 判定
  if best_score < score:
    best_score = score
    best_param = clf.best_params_
    best_model = model.__class__.__name__
```

学習結果のスコアは0.778と、ちゃんと上がってくれていますね。
paramはこんな感じ。

```python
print(best_param)

{'criterion': 'gini', 'max_depth': 9, 'n_estimators': 25}
```


# Model作成その④　アンサンブル
いよいよ大詰め。
②、③で作成したLightGBMとRandomForestをアンサンブルし、最終的な予測結果を算出します！

アンサンブルの手法も調べれば色々とありますが、ここではスタッキングを使ってみたいと思います。
スタッキングとは簡単にいえば「各Model予測結果を説明変数に、最終的な予測値を算出する」手法です（合ってる……？）。

今回で言うとLightGBMとRandomForestでそれぞれ予測を行い、それらから更にサポートベクターマシンで予測をして、結果を得ます。
こんな関数を用意しました。

```python
def predict(X_train, y_train, X_test, mode):
  # 結果格納用の配列
  preds = []
  preds_test = []
  idxes = []
  # クロスバリデーションで予測を実行
  kf = KFold(n_splits=4, shuffle=True, random_state=71)
  for tr_idx, va_idx in kf.split(X_train):
    # 学習データ、評価データに分割
    tr_x, va_x = X_train.iloc[tr_idx], X_train.iloc[va_idx]
    tr_y, va_y = y_train.iloc[tr_idx], y_train.iloc[va_idx]
    # modelを構築
    if mode == 'LightGBM':
      params = {'bagging_fraction': 0.4600347572555584,
                'bagging_freq': 5,
                'boosting_type': 'gbdt',
                'feature_fraction': 0.7,
                'feature_pre_filter': False,
                'lambda_l1': 0.004418000666138604,
                'lambda_l2': 8.039538280454251e-06,
                'min_child_samples': 20,
                'num_leaves': 4,
                'objective': 'binary',
                'seed': 71,
                'task': 'train',
                'verbose': 0}
      lgb_train = lgb.Dataset(tr_x, tr_y)
      lgb_eval = lgb.Dataset(tr_x, tr_y, reference=lgb_train)
      model = lgb.train(params, lgb_train, num_boost_round=1000, early_stopping_rounds=50, valid_sets=lgb_eval)
    elif mode == 'RandomForest':
      model = RandomForestClassifier(random_state=123, n_estimators=9, criterion='gini', max_depth=25)
      model = model.fit(tr_x, tr_y)
    elif mode == 'SVM':
      model = svm.LinearSVC()
      model = model.fit(tr_x, tr_y)
    # 予測値を算出
    pred = model.predict(va_x)
    preds.append(pred)
    pred_test = model.predict(X_test)
    preds_test.append(pred_test)
    idxes.append(idx)

  # バリデーションデータに対する予測値を連結、その後元の順序に直す
  idxes = np.concatenate(idxes)
  preds = np.concatenate(preds, axis=0)
  pred_train = preds[np.argsort(idxes)]
  
  # テストデータに対する平均値を取得
  preds_test = np.mean(preds_test, axis=0)
  return pred_train, preds_test
```

この関数からそれぞれLightGBM、RandomForestによるtrainデータに対する予測結果、testデータに対する予測結果を得て（第一層）、それらを新たなtrainデータ、testデータとしてSVMによる予測を実施します（第二層）。

このコードで出力した予測値をSubmitしたところ、0.8005というスコアが得られました！
Leaderboardを見る限り0.8が一つの壁だったようで、それを超えられたのは良かったです。
このスコアにより、コンペの最終順位は11位となりました。
なおパラメーターはこのFoldに完全に適したものではないのですが、デフォルト時のスコアが0.8000だったので、やはり一定の効果は得られているようです。


# まとめ
まぁ、正直、1桁順位で終わりたかったなー！
初めにアンサンブルしたやつをSubmitしたときは7位くらいだったのですが、そっからスコアを上げられずズルズルと11位に……。
同じスコアの方も何人かいらっしゃいますが、コンペでは先着順に順位が決定されるので。

あと個人的な反省点で言うと、特徴量の創出。
ほんとは先にこっちに取りかからなきゃダメですよね。
フォーラムを見ると既存の説明変数を四則演算するだけでもスコアを伸ばせるケースがあったようです。
自分も最後の追い込みでいくつか試してみて、訓練時のスコアは伸びたのですがLeaderboardではどうも振るいませんでした。
ギリギリになってやることじゃないですね。
この辺でどハマりするとすぐ投稿回数上限来ちゃうし、時間的にも精神的にもその他諸々、余裕があるときにアタリつけなきゃいけませんね。完全に戒め。

しかしビギナー向けとはいえ、こういったコンペに参加するのは初めてで、試行錯誤をしながらスコアを伸ばしたり、順位を見ながらやきもきしたりと非常に楽しい経験となりました。

さぁ！　次はファンダメンタルズ分析か、レモンの識別か……。