1歩踏み込んだチュートリアル

1歩踏み込んだチュートリアル(前半)

このチュートリアルは,運営公式のチュートリアルから一歩踏み込んだチュートリアルです.
内容としては,データの前処理,モデルの作成,交差検証,パラメーターチューニングに重点を置き,
コンペを戦うのに必要な実践的なテクニックを説明することを主目的とし,今まで筆者が勉強したことをまとめようと思います.
筆者自身,まだ勉強中の身ですので,ご指摘,ご意見等,コメントにてお待ちしております.

全体の流れ
  1. 必要なモジュールインポート
  2. データのロード
  3. EDA
  4. データの前処理
  5. モデルの作成
  6. モデルによる検証(前半はここまで)
  7. ハイパーパラメーターチューニング
  8. 提出

必要なモジュールインポート

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

EDAは日本語で探索的データ解析と訳されます.データを眺めて前処理や変換に活かします.
今回は公式チュートリアルの他,トピックにて[wakameさんがpandas-profilingでのEDAの結果をシェアされている](https://prob.space/competitions/game_winner/discussions/wakame-Postc622049ea15c3ac48925)ので,そちらを参考にしていきます.

EDAで最低限見るべきポイントは以下3つです.

  1. 欠損値の存在
  2. 値の型と意味
  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に使われるデータの割合は適当)
image.png
このことからわかることは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

時系列データを含むデータで予測モデルを作った場合,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スコアアップを目指す)

いろんなモデルを作ってアンサンブル
といった手順で取り組むといいと思います.
今回のチュートリアルは一旦ここまでとし,チューニングと提出は次回にしたいと思います.

添付データ

  • tutorial.ipynb?X-Amz-Expires=10800&X-Amz-Date=20240328T115332Z&X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=AKIAIP7GCBGMWPMZ42PQ
  • Favicon
    new user
    コメントするには 新規登録 もしくは ログイン が必要です。