はじめに

これは、ポケモンで機械学習モデリングをする試みについて記述した記事の後半部分です。前半から見ることをお勧めします。
【前編】ポケモンのタイプを固有の能力値パラメータを元に予測するモデルを構築する【単純集計】

前編のあらすじ

取得したデータを元に、ポケモンのタイプに対する能力値のヒストグラムの図示化を行なった。
しかし無情にも、タイプ間での分布の大きな違いは表れず、ポケモンの能力値だけではタイプ分類モデリングの構築は難しいことがモデル構築前から示されてしまった。

後半の主な内容

以上の難点から、逆に「能力値のみを特徴量としてどこまで精度をあげることができるのか」に焦点を当ててモデリングをしてみることにしました。
以下の順番で、説明していきたいと思います。

  • 【後編】ポケモンのタイプを固有の能力値パラメータを元に予測するモデルを構築する 【LightGBM】
    • LightGBMでモデルを構築する
    • ハイパーパラメータチューニング
    • テストデータを使用してモデルの評価する

LightGBMでモデルを構築する

精度をいかに高くできるかに挑戦...ということで、Kaggle等の機械学習コンペで最強と謳われる勾配ブースティング決定木の一つ、LightGBMを使用してモデリングしました。
勾配ブースティングについてはブースティング入門の記事に詳細が載っている為に割愛します。

データの前処理

まず、機械学習をする前にデータの目的変数である「タイプ」を対応する整数値に置き換え(ラベルエンコーディング)、訓練用・テスト用に分割します。


## データ前処理
from sklearn.preprocessing import  LabelEncoder
from sklearn.model_collection import train_test_split

#カテゴリー「タイプ」を整数に置き換えたカラム「"タイプ番号"」を作成
le = LabelEncoder()
le.fit(df_type.loc[:,df_type.columns[0]])
df_type["タイプ番号"] = le.transform(df_type.loc[:,df_type.columns[0]])

#訓練データとテストデータに分割
X = df_type.loc[:,['HP', 'こうげき', 'ぼうぎょ','とくこう', 'とくぼう', 'すばやさ','合計']].values
y =df_type.loc[:,['タイプ番号']].values
X_train,X_test,y_train,y_test = train_test_split(X,y, test_size=0.3)

モデル構築

LightGBMでのモデリングはlightgbmライブラリを使用することで実装できます。
今回構築するモデルは

  • 「タイプ」という多クラス分類
  • 不均衡データを使用

という特徴から、分類モデルlgb.LGBMClassifier 且つ、目的関数の引数objectiveに多クラス分類multiclass を設定します。


# LightBGM
import lightgbm as lgb

## 乱数シードの設定
seed = 0

# LightBGMモデルの初期化
model = lgb.LGBMClassifier(
    boosting_type='gbdt', #勾配決定木 (デフォルト)
    objective = 'multiclass', #多クラス分類
    random_state=seed, #乱数シード
    n_estimators=10000, #ブーストする木の数
    class_weight = "balanced" #不均衡データのチューニング時に必要
    )

ハイパーパラメータチューニング

交差検証法によるモデルの評価

構築したモデルをテストデータで評価する前に、まず訓練データを使用してモデルを検証します。
具体的に、訓練用データを更に2つに分割して、それぞれをモデルの学習と評価に使用します。

今回は交差検証法を用いて、学習用と評価用データをそれぞれ入れ替えて全パターンの学習結果の統計量を評価値とします。これによって、一通り全訓練データを反映した評価値が得られます。

モデル評価の際の指標

モデルを評価するための指標は、正解率ではなく再現率と適合率を統合的に評価できる指標である F1 スコア を用います。

指標選択については、下記サイトの「分類の評価指標」を参考にしました。
機械学習のパラメータチューニングを「これでもか!」というくらい丁寧に解説


#fitパラメータ
fit_params = {
    'callbacks': [lgb.early_stopping(stopping_rounds=10,verbose=0)],
    'eval_metric': "auc_mu", #評価指数
    'eval_set': [(X_train, y_train)],
}

# KFoldで交差検証法の分割個数の指定
cv = KFold(n_splits=3, shuffle=True, random_state=seed)  

# 評価指標の設定
scoring = {
           "f1":"f1_macro"
           } 

#交差検証を行う
scores = cross_validate(model, X_train,y_train,
    cv = cv,scoring = scoring,
    n_jobs=-1,
    fit_params=fit_params,
    error_score='raise'
)

# F1_macro score
df_scores = pd.DataFrame(scores)
print(df_scores.agg(["mean","std"])["test_f1"])
index test_f1
mean 0.12091571660388177
std 0.006092140440963509

平均スコアが0.12...やっぱりとても低いですね。

ハイパーパラメータチューニング

少しでもスコアを高める為に、ハイパーパラメーターチューニングを行います。
ハイパーパラメータとは、学習によって推定されるパラメータではなく、予め人間側が決めるパラメータです。
例えば、過剰学習を防ぐための正則化の強さはその一つです。
何も言及しない場合はデフォルトのパラメータ値が使用されます。

本記事では、下記のサイトのコードを参考にしてハイパーパラメータの範囲の指定・ベイズ最適化 optuna による最適なパラメータの選択等を行いました。
LightGBMのパラメータチューニングまとめ


import optuna
import time
start = time.time()

def bayes_objective(trial):
  #タプルでハイパーパラメータと範囲を指定
  params = {
      'reg_alpha': trial.suggest_float('reg_alpha', 0.0001,10, log=True), 
      'reg_lambda': trial.suggest_float('reg_lambda',0.0001, 0.01, log=True),
      'num_leaves': trial.suggest_int('num_leaves',50, 200),
      'colsample_bytree': trial.suggest_float('colsample_bytree',0, 0.4), 
      'subsample': trial.suggest_float('subsample', 0.8, 1.0),
      'subsample_freq': trial.suggest_int('subsample_freq', 0, 3),
      'min_child_samples': trial.suggest_int('min_child_samples',0, 60),
      'learning_rate': trial.suggest_float('learning_rate',0.0001,0.1, log=True),
      'min_data_in_leaf': trial.suggest_int('min_data_in_leaf',1,10),
      'min_gain_to_split': trial.suggest_float('min_gain_to_split',0,0.1),
      'feature_fraction': trial.suggest_float('feature_fraction', 0.4, 1.0),
      'bagging_fraction' : trial.suggest_float('bagging_fraction', 0.4, 1.0),
      'bagging_freq' : trial.suggest_int('bagging_freq', 0, 10),
      }
  #モデルパラメータに使用
  model.set_params(**params)

  #交差検証法
  scoring = "f1_macro"
  scores = cross_validate(model, X_train,y_train, cv = cv,
                       scoring = scoring,
                       n_jobs=-1,
                       fit_params=fit_params)
  val = scores['test_score'].mean()
  return val

#ベイズ最適化の実行
study = optuna.create_study(direction='maximize',
                            sampler=optuna.samplers.TPESampler(seed=seed)) #ベイズ最適化のインスタンス

study.optimize(bayes_objective, n_trials=400)

# 最適パラメータの表示と保持
best_params = study.best_trial.params #ベストパラメータをbest_paramsに格納
best_score = study.best_trial.value
print(f'最適パラメータ {best_params}\nスコア {best_score}')
print(f'所要時間{time.time() - start}秒')

結果として最適パラメータと、その時のスコアが手に入りました。

最適パラメータ 出力
reg_alpha 0.000132
reg_lambda 0.000800
num_leaves 150.000000
colsample_bytree 0.227981
subsample 0.987253
subsample_freq 1.000000
min_child_samples 58.000000
learning_rate 0.000131
min_data_in_leaf 10.000000
min_gain_to_split 0.031736
feature_fraction 0.801369
bagging_fraction 0.422356
bagging_freq 5.000000
スコア 出力
Best f1_macro 0.1427552942219895

検証スコア平均は0.14と、チューニング前と比較するとスコアが大きくなっていることがわかります。

また、パラメータチューニング中の試行一回一回におけるF1スコアの値を図示化すると、徐々にスコアが高く、安定する値に収束することが確認できます。
即ち、このハイパーパラメータが現状最適であることが直感的に理解できます。


## 試行10回毎におけるスコアを保存
result = []
iter_num = []
for i in range(0,len(study.trials),10):
  result.append(study.trials[i].values[0])
  iter_num.append(i)
## 試行毎のスコアの図示化
plt.plot(iter_num, result, marker="o", color="black",linestyle="dashed")
plt.grid(True)
plt.xlabel("iteration")
plt.ylabel("f1 score")
plt.show()

各試行毎におけるスコアの変化

テストデータを使用してモデルの評価する

改めて、最適なハイパーパラメータをモデルにセットして、テストデータを評価します。


## ベストパラメータをセット
model.set_params(**best_params)

#fitパラメータ
fit_params = {
    'callbacks': [lgb.early_stopping(stopping_rounds=10,verbose=0)],
    'eval_metric': "auc_mu", #評価指数
    'eval_set': [(X_test, y_test)]
    }

eval_set = [(X_test, y_test)]

# 訓練データでモデルフィット
model.fit(X_train,y_train,
            eval_set=eval_set,
            callbacks=callbacks,
            verbose = -1
            )

# テストデータでのタイプ推定
y_pred = model.predict(X_test)

## 推定結果とテストデータ間でのF1スコア表示
print("f1_macro",f1_score(y_test, y_pred, average="macro"))
出力
f1_macro 0.14211315834616967

最終的な結果として、テストデータのF1スコア 0.14 を得ることができました。

結果・考察・感想

結果

前半のヒストグラムの段階で分類が非常に難しそうであると予想をしていました。実際にその通り、能力値のみを特徴量とすると非常に低いスコア0.12になることが示ました。
ハイパーパラメータチューニングした結果、0.12から0.14まで多少スコアをあげることができました。

また、特徴量重要度を算出すると攻撃力と素早さがタイプ分類に重要であることが示ます。


df_feature_importances = pd.DataFrame(model.feature_importances_, index= df_type.loc[:,df_type.columns[1:-1]].columns, columns=["特徴量重要度"])
df_feature_importances = df_feature_importances.sort_values("特徴量重要度")

plt.barh([0,1,2,3,4,5,6],df_feature_importances["特徴量重要度"].values, height=0.5)
for num in [0,1,2,3,4,5,6]:
  val = df_feature_importances["特徴量重要度"].values[num]
  plt.text(df_feature_importances["特徴量重要度"].values[num], num, f"{val}")
plt.yticks([0,1,2,3,4,5,6], df_feature_importances.index)
plt.xlabel("feature_importances")
plt.ylabel("features")
plt.title("特徴量重要度")
plt.grid(True)
plt.show()

特徴量重要度

考察

このことから、本当により精度の高いモデルを作成する為には、能力値の他に今回省いた特性や、このデータにはない他特徴量 (例えば、進化段階・進化レベル・初登場した世代・覚える技など) を加える必要があると結論に至りました。本当はオンライン対戦環境で使用される頻度とかのデータも解析に使いたいです

感想

LightGBMモデル、ベイズ最適化によるハイパーチューニングなど今回初めて本格的に使いました。色々と大目にみてもらえると幸いです。
iris等の備え付けの練習用のデータベースと違って実データは思う様に結果が出ず、改めてモデルの改良・結果の解釈等の難しさを思い知りました。
次はもう少し知識をつけてからまた挑もうと思います。

最後に

最後に、どうせなので実際のポケモンの能力値データを入れて、どのタイプを予測するか見てみます。
まずは前編で値ズレがあった為にハブられてしまったこのポケモン...そう、ジガルデです。

index 図鑑番号 ポケモン名 タイプ1 タイプ2 通常特性1 通常特性2 夢特性 HP こうげき ぼうぎょ とくこう とくぼう すばやさ 合計
814 718 ジガルデ10% ドラゴン じめん オーラブレイク スワームチェンジ 54 100 71 61 85 115 486 NaN
815 718-1 ジガルデ50% ドラゴン じめん オーラブレイク スワームチェンジ 108 100 121 81 95 95 600 NaN

#ジガルデ10%, 50%
pokemon = pd.read_csv("pokemon_status.csv", delimiter=',', header=0,encoding="SHIFT-JIS")

##タイプ予測
X_zygarde = pokemon.iloc[[814,815],range(6,13)].values
y_pred = model.predict(X_zygarde)

## ラベルデコーディング
print(le.inverse_transform(y_pred))
ポケモン名 タイプ予測
ジガルデ10% ノーマル
ジガルデ50% エスパー

はい。全然ハズレですね。
次に重要特徴量の一つ...素早さが高そうなポケモン...よし、フーディン、君に決めた !!


X_Alakazam= pokemon[pokemon["ポケモン名"] == "フーディン"].iloc[:,range(7,14)].values
y_pred = model.predict(X_Alakazam)
le.inverse_transform(y_pred)
ポケモン名 タイプ予測
フーディン エスパー

当たりました !! よかった...
最後にポケモンといえばこの子、ピカチュウ、君に決めた !!


X_pikachu = pokemon[pokemon["ポケモン名"] == "ピカチュウ"].iloc[:,range(7,14)].values
y_pred = model.predict(X_pikachu)
le.inverse_transform(y_pred)
ポケモン名 タイプ予測
ピカチュウ むし

_人人人人人人人_
> むしタイプ <
 ̄Y^Y^Y^Y^Y^Y ̄

トキワのもりは大多数がむしポケモンだから、実質ピカチュウもむしタイプ

以上最後までお付き合い戴きありがとうございました。

reference