wisp
このチュートリアルは,運営公式のチュートリアルから一歩踏み込んだチュートリアルです.
内容としては,データの前処理,モデルの作成,交差検証,パラメーターチューニングに重点を置き,
コンペを戦うのに必要な実践的なテクニックを説明することを主目的とし,今まで筆者が勉強したことをまとめようと思います.
筆者自身,まだ勉強中の身ですので,ご指摘,ご意見等,コメントにてお待ちしております.
from typing import List, Callable, Set
from copy import deepcopy
from collections import defaultdict
import pandas as pd
from pandas import DataFrame, Series
from sklearn.preprocessing import LabelEncoder, PowerTransformer, MultiLabelBinarizer
from sklearn.metrics import accuracy_score
from sklearn.model_selection import StratifiedKFold,GridSearchCV
from sklearn.pipeline import Pipeline
from sklearn.compose import make_column_transformer
from sklearn.utils.validation import check_is_fitted
from lightgbm import LGBMClassifier
import matplotlib.pyplot as plt
def load_data():
train_df: DataFrame = pd.read_csv("train_data.csv", index_col='id')
train_X: DataFrame = train_df.drop("y",axis=1)
train_y: Series = train_df.loc[:,"y"]
test_X: DataFrame = pd.read_csv("test_data.csv", index_col='id')
return train_X,train_y,test_X
train_X,train_y,test_X = load_data()
train_X.head()
period | game-ver | lobby-mode | lobby | mode | stage | A1-weapon | A1-rank | A1-level | A2-weapon | ... | B1-level | B2-weapon | B2-rank | B2-level | B3-weapon | B3-rank | B3-level | B4-weapon | B4-rank | B4-level | |
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
id | |||||||||||||||||||||
1 | 2019-10-15T20:00:00+00:00 | 5.0.1 | regular | standard | nawabari | sumeshi | sshooter_becchu | NaN | 139 | soytuber_custom | ... | 28 | hokusai_becchu | NaN | 26.0 | herocharger_replica | NaN | 68.0 | sharp_neo | NaN | 31.0 |
2 | 2019-12-14T04:00:00+00:00 | 5.0.1 | regular | standard | nawabari | arowana | parashelter_sorella | NaN | 198 | jetsweeper | ... | 83 | squiclean_b | NaN | 118.0 | campingshelter | NaN | 168.0 | sputtery_clear | NaN | 151.0 |
3 | 2019-12-25T14:00:00+00:00 | 5.0.1 | gachi | standard | hoko | ama | nzap89 | a- | 114 | quadhopper_black | ... | 50 | nzap85 | a+ | 163.0 | prime_becchu | a- | 160.0 | dualsweeper_custom | a | 126.0 |
4 | 2019-11-11T14:00:00+00:00 | 5.0.1 | regular | standard | nawabari | engawa | bamboo14mk1 | NaN | 336 | splatroller_becchu | ... | 273 | liter4k | NaN | 189.0 | promodeler_mg | NaN | 194.0 | hotblaster_custom | NaN | 391.0 |
5 | 2019-12-14T06:00:00+00:00 | 5.0.1 | gachi | standard | hoko | chozame | bold_7 | x | 299 | hissen_hue | ... | 101 | sputtery_hue | x | 45.0 | bucketslosher_soda | x | 246.0 | wakaba | x | 160.0 |
5 rows × 30 columns
EDAは日本語で探索的データ解析と訳されます.データを眺めて前処理や変換に活かします.
今回は公式チュートリアルの他,トピックにて[wakameさんがpandas-profilingでのEDAの結果をシェアされている](https://prob.space/competitions/game_winner/discussions/wakame-Postc622049ea15c3ac48925)ので,そちらを参考にしていきます.
EDAで最低限見るべきポイントは以下3つです.
1に関しては,欠損値があると途中の前処理がうまくいかなかったり,モデルによってはエラーを吐いたりするので,
などの方法で対応する必要があります.
今回はrankと一部のweaponに欠損値が存在します.
なるべくデータは使いたいので欠損値として処理することにしましょう.
2に関しては,値の型と意味によって,次に行う前処理が変わってきます.
今回だと,ほとんどのデータが文字列となっていて,カテゴリーデータになっています.
しかし,rankなどは,[スプラトゥーンの攻略サイト](https://wiki.denfaminicogamer.jp/Splatoon2/%E3%82%A6%E3%83%87%E3%83%9E%E3%82%A8)を見るとウデマエという一種のプレイヤーの強さの指標と同じ意味であると言っていいでしょう.よって,rankはカテゴリーデータとして扱うより,数値データとして変換した方が意味がありそうです.(ちなみにこのように問題のテーマにある背景知識をドメイン知識と呼んだりします.)
3に関しては,例えば,今回だとgame_verやlobbyは1つの値しかとらないため,意味がありません.そのようなデータは削除しましょう.
前処理とは,データに任意の処理と変換を加えることでデータを機械学習モデルが読み取れる形に加工することです.
EDAで得た知見をもとに実際にデータを加工していくのですが,注意すべきポイントがあります.
それは再利用可能なコードを書くことです.
データ分析では,(EDA→)前処理→(モデルの構築→)効果検証→前処理....というループを無限に繰り返して精度を上げていくことになります.
意識せずにコードを書いていると,突然エラーを吐いて,どこが原因でエラーが出たのかわからなくなって無駄な時間を要することになります.
また,上記のサイクルを回すにあたって,ぐちゃぐちゃなコードを書いていると,試行錯誤に要するコストが大きくなってしまうので,結果として試行回数が小さくなってしまうのです.
今回は再利用可能を意識してコーディングしていこうと思います.
主に使うモジュールはsklearnのpipelineです.
#utils
def _unique_values_from_df(X: DataFrame)->list:
cols = X.columns
unique_values: Set = set()
for col in cols:
unique_values.update(pd.unique(X[col]))
return list(unique_values)
def _label_encode(X:DataFrame, cols:list, le: LabelEncoder)->DataFrame:
for col in cols:
X[col] = le.transform(X[col])
return X
def team_feat(team:str = "A", item:str = "weapon")->List[str]:
"""
param: team: A | B | both
param: item: weapon | rank | level
return: list of columns name.
"""
assert item in ["weapon","rank","level"]
assert team in ["A","B","both"]
def _list_embed(team:str,item:str)->List[str]:
dst = []
for i in range(1,5):
col_name = f"{team}{i}-{item}"
dst.append(col_name)
return dst
A_feats = _list_embed("A",item)
B_feats = _list_embed("B",item)
both_feats = A_feats + B_feats
team_feats_dict = {"A":A_feats,"B":B_feats,"both":both_feats}
return team_feats_dict[team]
from sklearn.base import TransformerMixin,BaseEstimator
## BaseEstimator,TransformerMixinを継承した前処理のためのクラスを作る.
## fitやtransformなどのメソッドが共通するので,sklearnの便利なモジュールを組み合わせることで生産性が高まります.
class BaseTransformer:
def fit(self,X, y=None, **fit_params):
return self
def transform(self,X,y=None):
return X
def _valid_cols_exist(self,X: DataFrame, col_names: List[str]):
"""
カラムの存在確認
"""
inclusion: set = set(col_names).issubset(set(X.columns))
if inclusion:
pass
else:
not_found_cols: set = set(col_names) - set(col_names).intersection(set(X.columns))
raise Exception(f"{list(not_found_cols)} cannot be found...")
# pandasのDatetime型に変換
# このように,あまり変わらない(=どの変換にも必須)の前処理はクラス化しておくと後々便利
class PeriodTransformer(BaseTransformer,TransformerMixin, BaseEstimator):
def __init__(self,period_format:str = '%Y-%m-%dT%H:%M:%S+00:00'):
self.period_format = period_format
def transform(self,X,y=None):
self._valid_cols_exist(X,['period'])
X['period'] = pd.to_datetime(X['period'], format=self.period_format)
print(X.shape)
return X
class DeleteColumnTransformer(BaseTransformer,TransformerMixin, BaseEstimator):
def __init__(self,deleted_columns:List[str]=["game-ver","lobby","period"]):
self.deleted_columns = deleted_columns
def transform(self,X,y=None):
self._valid_cols_exist(X,self.deleted_columns)
X = X.drop(self.deleted_columns,axis=1)
print(X.shape)
return X
class FloatTransformer(BaseTransformer,TransformerMixin, BaseEstimator):
def transform(self,X,y=None):
X.astype("float")
return X
# どのモデルにも共通するけど,試行錯誤でコロコロ変えたい前処理は以下の関数に記述
def _preprocess(X: DataFrame,y=None)->DataFrame:
#時間・日時に関する特徴量
X["day_of_the_week"] = X["period"].dt.dayofweek
X["hour"] = X["period"].dt.hour
return X
class NormalLabelEncoder(BaseTransformer,TransformerMixin, BaseEstimator):
def __init__(self,cols:list,fillna_word:str="none"):
self.cols = cols
self.fillna_word = fillna_word
self.encoders = defaultdict(LabelEncoder)
def fit(self,X,y=None):
for col in self.cols:
self.encoders[col].fit(X[col].fillna(self.fillna_word))
return self
def transform(self,X,y=None):
for col in self.cols:
X[col] = self.encoders[col].transform(X[col].fillna(self.fillna_word))
print(X.shape)
return X
class LevelPreprocessor(BaseTransformer,TransformerMixin, BaseEstimator):
def __init__(self,fillna:str="mean"):
"""
param fillna: how to fill nan cell.
"mean" | "median" | (本当は"predict"も入れて,nanの値を予測することもしたかったのですが,
ちょっと実装に時間がかかりそうなので,このチュートリアルでは取り上げません)
"""
self.fillna = fillna
self.level_cols = team_feat("both","level")
self.fillna_dict = dict()
def fit(self,X,y=None):
for level_col in self.level_cols:
fillna_val = eval(f"X[level_col].{self.fillna}()")
self.fillna_dict[level_col] = fillna_val
return self
def transform(self,X,y=None):
for level_col in self.level_cols:
fillna_val = self.fillna_dict[level_col]
X[level_col].fillna(fillna_val,inplace=True)
print(X.shape)
return X
# モデルごとに前処理を分ける.今回はGBT系のアルゴリズム用の前処理クラスを書く.
class GBTPreprocessor(BaseTransformer,TransformerMixin, BaseEstimator):
def __init__(self,preprocess_fn:Callable,le:LabelEncoder = LabelEncoder()):
self.le = le
self.preprocess_fn = preprocess_fn
def transform(self,X,y=None):
X = self.preprocess_fn(X)
print(X.shape)
return X
# 以下の関数は公式チュートリアルを参考にした.
def trans_weapon(df, cols=team_feat("A","weapon"))->DataFrame:
le = LabelEncoder()
weapon = df.fillna('none')[cols]
le.fit(weapon[cols[3]])#weaponの1~3には"none"が含まれないので
for col in cols:
weapon[col] = le.transform(weapon[col])
return weapon,le
def weapon_feature_df(df)->DataFrame:
a_weapon, a_weapon_transformer = trans_weapon(df, cols=team_feat("A","weapon"))
b_weapon, b_weapon_transformer = trans_weapon(df, cols=team_feat("B","weapon"))
X = pd.concat([a_weapon, b_weapon], axis=1)
return X
class WeaponLabelEncoder(BaseTransformer,TransformerMixin, BaseEstimator):
def __init__(self,le=LabelEncoder()):
self.le = le
self.weapon_cols = team_feat("both","weapon")
def fit(self,X,y=None):
weapons = _unique_values_from_df(X[self.weapon_cols].fillna('none'))
self.le.fit(weapons)
return self
def transform(self,X,y=None):
X[self.weapon_cols] = X[self.weapon_cols].fillna('none')
X = _label_encode(X, self.weapon_cols, self.le)
print(X.shape)
return X
#カラムごとに処理を加えたい場合,
#↑のWeaponLabelEncoderの実装のように説明変数を返せばいいのですが,
#カラムごとに行いたい処理がある場合,make_column_transformerを用いることで実装できます.
#参考:https://qiita.com/R0w0/items/3b3d8e660b8abc1f804d
pt = make_column_transformer(
(PowerTransformer(), team_feat("both","level")),remainder="passthrough" # (procedure, cols)f
(PowerTransformer(), team_feat("both","level")),remainder="passthrough"
# (OneHotEncoder(sparse=False, drop="first"), cat_cols)
)
前処理では,BaseTransformer,TransformerMixin, BaseEstimatorを継承したクラスを作り,一見すると非効率に見える方法でコーディングしました.このパートではsklearnのpipelineという機能を使いモデルを構築していきます.
pipelineを使うことで,モデルごとに前処理を書き換えたりすることが容易になります.
categorical_cols = ['game-ver', 'lobby-mode', 'lobby', 'mode', 'stage', 'A1-rank', 'A2-rank', 'A3-rank', 'A4-rank', 'B1-rank', 'B2-rank', 'B3-rank','B4-rank']
clf = Pipeline(
[
("PeriodTransformer",PeriodTransformer()),
("LevelPreprocessor",LevelPreprocessor()),
("NormalLabelEncoder",NormalLabelEncoder(categorical_cols)),
("WeaponLabelEncoder",WeaponLabelEncoder()),
("GBTPreprocessor",GBTPreprocessor(_preprocess)),
("DeleteColumnTransformer",DeleteColumnTransformer()),
("FloatTransformer",FloatTransformer()),
("pt",pt),
("lgbm",LGBMClassifier())
]
)
clf.fit(train_X,train_y)
Pipeline(memory=None, steps=[('periodtransformer', PeriodTransformer(period_format='%Y-%m-%dT%H:%M:%S+00:00')), ('levelpreprocessor', LevelPreprocessor(fillna='mean')), ('normallabelencoder', NormalLabelEncoder(cols=['game-ver', 'lobby-mode', 'lobby', 'mode', 'stage', 'A1-rank', 'A2-rank', 'A3-rank', 'A4-rank', 'B1-rank', 'B2-rank', 'B3-rank', 'B4-rank'], fillna_word... LGBMClassifier(boosting_type='gbdt', class_weight=None, colsample_bytree=1.0, importance_type='split', learning_rate=0.1, max_depth=-1, min_child_samples=20, min_child_weight=0.001, min_split_gain=0.0, n_estimators=100, n_jobs=-1, num_leaves=31, objective=None, random_state=None, reg_alpha=0.0, reg_lambda=0.0, silent=True, subsample=1.0, subsample_for_bin=200000, subsample_freq=0))], verbose=False)
さて,今までのステップでは前処理を含めた予測モデルを構築しました.
今から私たちはスコアアップ(より予測精度の高いモデルを作ること)を目指して,前処理をいじったり,モデルをいじったり,パラメーターをチューニングしたりするわけですが,その試行錯誤が「いいモデルを作ることに貢献しているか?」を測る,つまりモデルの良さを測る指標が必要になります.このパートではそのモデルの検証について詳しく見ていきます.
評価指標は今回コンペでは正解率([accuracy](https://prob.space/competitions/game_winner))で評価されます.
また,最終のモデルの評価にはtest_dataの一部が使用されます.(以下画像を参照.Private LBに使われるデータの割合は適当)
このことからわかることはPublicリーダーボードとPrivateリーダーボードでのスコアには差が生まれるということです.
Publicで最もスコアが高いモデルが決してPrivateでも最高のスコアを出すとは限らないのです.
よって,私たちは交差検証(Cross Validation:CV)を行い手元のtrainデータによるスコアとpublicのスコアがなるべく高いモデルを選ぶべきなのです.
交差検証についてはu++さんの[こちらの記事](https://upura.hatenablog.com/entry/2018/12/04/205200#%E4%BA%A4%E5%B7%AE%E6%A4%9C%E8%A8%BCCross-ValidationCV%E3%82%92%E5%AE%9F%E8%A1%8C%E3%81%97%E3%81%9F%E5%A0%B4%E5%90%88)が詳しいです.
以下,このu++さんの記事に従って,CVを実装していきましょう.
時系列データを含むデータで予測モデルを作った場合,CVは時系列ごとにfoldを分けるべきという考え方があります.
なぜならば,私たちが作った予測モデルというのは,常に最新のデータに対して(=訓練データに含まれていない日時,時間におけるデータに対して)よりよい予測をする必要があるからです.
今回のコンペで与えられたデータの日時を可視化してみましょう.
train_period_ser = PeriodTransformer().transform(train_X)["period"]
test_period_ser = PeriodTransformer().transform(test_X)["period"]
plt.figure(figsize=(7,7))
train_period_ser.hist(bins=20)
plt.ylabel("train_data_nume")
plt.xlabel("datetime")
plt.show()
plt.figure(figsize=(7,7))
test_period_ser.hist(bins=20)
plt.ylabel("test_data_nume")
plt.xlabel("datetime")
plt.show()
見事にデータの分布&期間がほぼ一緒になりました.よって,時系列ごとに分けたCVを実装する必要はなさそうです.
続いてその他の戦績に大きく影響を与えそうな,レベルの分布などをEDAで示した,[wakameさんがシェアしてくださったpandas-profilingでのEDAの結果](https://prob.space/competitions/game_winner/discussions/wakame-Postc622049ea15c3ac48925)で確認してみると,こちらも見事にほとんど一致しています.かなりきれいなデータであるといえそうです(ありがとう...運営さん)よって特に何も考えずに[sklearnのStratifiedKFol](https://scikit-learn.org/stable/modules/generated/sklearn.model_selection.StratifiedKFold.html#sklearn.model_selection.StratifiedKFold)を使い,CVを実装していきます.
def cv_score(clf,X,y,n_splits=5,kf=StratifiedKFold(n_splits=5))->float:
score = 0.
counter = 1
for train_index, valid_index in kf.split(X, y):
train_X,valid_X = X.iloc[train_index,:].copy(),X.iloc[valid_index,:].copy()
train_y,valid_y = y.iloc[train_index],y.iloc[valid_index]
c_clf = deepcopy(clf)
c_clf.fit(train_X,train_y)
preds = c_clf.predict(valid_X)
acc_score = accuracy_score(valid_y,preds)
print(f"fold{counter} score is :{acc_score}")
score += acc_score
counter += 1
return score / n_splits
cv_score(clf,train_X,train_y)
(52900, 32) (52900, 32) (52900, 32) (52900, 32) (52900, 32) (52900, 29) (13225, 32) (13225, 32) (13225, 32) (13225, 32) (13225, 32) (13225, 29) fold1 score is :0.5302079395085066 (52900, 32) (52900, 32) (52900, 32) (52900, 32) (52900, 32) (52900, 29) (13225, 32) (13225, 32) (13225, 32) (13225, 32) (13225, 32) (13225, 29) fold2 score is :0.534366729678639 (52900, 32) (52900, 32) (52900, 32) (52900, 32) (52900, 32) (52900, 29) (13225, 32) (13225, 32) (13225, 32) (13225, 32) (13225, 32) (13225, 29) fold3 score is :0.5283931947069943 (52900, 32) (52900, 32) (52900, 32) (52900, 32) (52900, 32) (52900, 29) (13225, 32) (13225, 32) (13225, 32) (13225, 32) (13225, 32) (13225, 29) fold4 score is :0.5354253308128545 (52900, 32) (52900, 32) (52900, 32) (52900, 32) (52900, 32) (52900, 29) (13225, 32) (13225, 32) (13225, 32) (13225, 32) (13225, 32) (13225, 29) fold5 score is :0.5295274102079395
0.5315841209829868
flod間のスコア差もほとんどないので,ある程度いいCVが切れたといえる(?)でしょう.しかし,スコアが[accuracyの定義](https://scikit-learn.org/stable/modules/model_evaluation.html#accuracy-score)からスコアが0.526というのはほぼランダムな値を返しているようなものです.
このままではいけないのでいろいろスコアを上げていくための試行錯誤を行っていきます.
主に頑張るポイントは以下の3つでしょうか
1.前処理での特徴量エンジニアリング,変換
2.モデルのパラメーターチューニング
3.アンサンブル
2,3はコンペ終盤の微調整なので1番を頑張っていくのが,当分の間はよさそうです.
まず最初にある程度パラメーターチューニング(情報量によっても結構パラメーターが変わるので,ある程度のチューニングはたびたび行っておくべきだと思います.)
↓
特徴量エンジニアリング(コンペが終わる1週間ぐらい前まではシングルモデルで特徴量エンジニアリングでCVスコアアップを目指す)
↓
いろんなモデルを作ってアンサンブル
といった手順で取り組むといいと思います.
今回のチュートリアルは一旦ここまでとし,チューニングと提出は次回にしたいと思います.