こんにちは、kfskyです。今回はProbSpaceで行われていた「次の一投の行方を予測! プロ野球データ分析チャレンジ」の参加録になります。
普段の業務が忙しい中、時間を見つけて頑張っていきました。(野球は少年野球以来なので、プロ野球の中身はあまりわかっていません。。。)
コンペの内容
今回のコンペは、プロ野球の投球情報から、その投球での結果(アウトやヒットなど)を予測するコンペです。目的変数は8種類という、多クラス分類かつ、学習データとテストデータの構造が違うなどの特徴があります。多クラス分類のコンペティションは、あまり参加したことがなかったので、色々と参考になることが多かったです。
今回のコンペでは、訓練データとして、球種、投球の速度、打球の距離や方角といった情報が与えられています。これらのデータは、投球が行われるまでは未確定の情報となるため、テストデータからは除外されています。
本コンペを通じて、テストデータに含まれていない情報についても活用したモデル開発に親しんでいただけますと幸いです。
また、ストライクやファールに比べて2塁打やホームランといった打球は非常に数が少なくなります。このような不均衡データを用いていることも、本コンペの特徴です。
https://prob.space/competitions/npb
結果
Public:5位
Private:7位
今回、初めて一人で金圏に入賞できました!
Solution
解法の概要は以下になります。
githubにも上げていますので、こちらを参考にしていただければと思います。
Solution解説
今回は、LightGBMのシングルモデルで進めて行きました。色々実験している中で、今回のコンペティションで効いた特徴量や、勉強になったことを記載していこうと思います。
train_dataとtest_dataでの構造の違い
コンペ開始時から、train_dataとtest_dataの構造が違うことが気になっていました。(以下のトピックにも上がっています)
https://prob.space/competitions/npb/discussions/columbia2131-Post0bae12df9ec3007c4cce
学習データとテストデータの構造が異なると、CVとLBの相関が取れないなどの問題が出ると考えられます。なので、学習データとテストデータの構造を統一する必要があると感じていました。といったものの、中々思うように機能しない状態でした。トピックに上がっていたデータの作り方を参考に実装したら上手くいったので、こちらを参考にしました。トピックに上がっていたものから、random_stateを調整しました。
def random_sampling(input_df, n_sample=10):
dfs = []
tr_df = input_df[input_df['y'].notnull()].copy()
ts_df = input_df[input_df['y'].isnull()].copy()
for i in tqdm(range(n_sample)):
df = tr_df.groupby(['gameID', 'outCount']).apply(lambda x: x.sample(n=1, random_state=i+30)).reset_index(drop=True)
df['subGameID'] = df['gameID'] * n_sample + i
dfs.append(df)
ts_df['subGameID'] = ts_df['gameID'] * n_sample
return pd.concat(dfs,axis=0), ts_df
Fold設定
多クラス分類だったので、StratifiedKFoldで実施してましたが、GameIDでGroupKFoldで実施したところCVとLBの乖離が小さくなりました(train_dataとtest_dataがGameIDで分割さえていたのはわかっていたのに。と早く気づくべき点で反省点だと思います。)
# GroupKFold with random shuffle with a sklearn-like structure
class RandomGroupKFold:
def __init__(self, n_splits=4, shuffle=True, random_state=42):
self.n_splits = n_splits
self.shuffle = shuffle
self.random_state = random_state
def get_n_splits(self, X=None, y=None, groups=None):
return self.n_splits
def split(self, X=None, y=None, groups=None):
kf = KFold(n_splits=self.n_splits, shuffle=self.shuffle, random_state=self.random_state)
unique_ids = groups.unique()
for tr_group_idx, va_group_idx in kf.split(unique_ids):
# split group
tr_group, va_group = unique_ids[tr_group_idx], unique_ids[va_group_idx]
train_idx = np.where(groups.isin(tr_group))[0]
val_idx = np.where(groups.isin(va_group))[0]
yield train_idx, val_idx
特徴量生成に関して
今回の特徴量は以下を使用しました。
processes = [
get_oe_features,
get_ce_features,
get_ohe_features,
create_numeric_feature,
get_inning,
get_pvsb,
get_agg_inning,
get_agg_runner_status,
get_full_count,
get_match,
get_league,
get_count_batter,
get_HR_batter,
get_KK_pitcher,
ballPositionLabel_ratio,
ballPositionLabel_ratio_b,
ballPositionLabel_ratio_p_speed,
get_batting_average,
get_next_data,
get_prev_data,
get_next_diff,
get_prev_diff,
get_skip,
get_tfidf,
get_pivot_NMF9_features,
get_pivot_NMF27_features,
get_pivot_NMF54_features,
get_next_data_BSO,
get_prev_data_BSO,
get_next_diff_BSO,
get_prev_diff_BSO,
get_tfidf_p,
get_pivot_NMF9_features_BSO,
get_pivot_NMF27_features_BSO,
get_pivot_NMF54_features_BSO,
pca_ball_position_counts
]
上記の特徴量関数を使用しました。(関数それぞれに関しては、githubを参照してください)また、学習データにしかないデータ(BallY, pitchType)を予測することで、特徴量にすることもしました。
CV, LBに効いた特徴量は以下のようなものが挙げられます。
● BallYの予測
学習データにしかないデータを予測して、テストデータに追加することで特徴量として使用するアプローチを立てましたが、BallY効きました。
● counts, runnerでの特徴量生成
カウントの状態をまとめるなどして、新たな特徴量にすることも効果がありました。(LightGBMなどの勾配ブースティング系では、この様にstr型の特徴量を組み合わせてると効果がある場合があります。)
train_df["total_counts2"] = train_df[['S', 'B', 'O', 'totalPitchingCount']].apply(lambda x: '{}-{}-{}-{}'.format(x[0], x[1], x[2],x[3]),axis=1)
test_df["total_counts2"] = test_df[['S', 'B', 'O','totalPitchingCount']].apply(lambda x: '{}-{}-{}-{}'.format(x[0], x[1], x[2],x[3]),axis=1)
● LightGBMのパラメータ設定
多クラス分類かつ不均衡データの中で、LightGBMのパラメータのclass_weightをいじると効果がありました。
"class_weight":"balanced"
● 前後特徴量
トピックから拝借した特徴量も効きました。(https://prob.space/topics/DT-SN-Post2126e8f25865e24a1cc4)
def get_diff_feature(input_df, value_col, periods, in_inning=True, aggfunc=np.median):
pivot_df = pd.pivot_table(input_df, index='subGameID', columns='outCount', values=value_col, aggfunc=aggfunc)
if in_inning:
dfs = []
for inning in range(9):
df0 = pivot_df.loc[:, [out+inning*6 for out in range(0,3)]].diff(periods, axis=1)
df1 = pivot_df.loc[:, [out+inning*6 for out in range(3,6)]].diff(periods, axis=1)
dfs += [df0, df1]
pivot_df = pd.concat(dfs, axis=1).stack()
else:
df0 = pivot_df.loc[:, [out+inning*6 for inning in range(9) for out in range(0,3)]].diff(periods, axis=1)
df1 = pivot_df.loc[:, [out+inning*6 for inning in range(9) for out in range(3,6)]].diff(periods, axis=1)
pivot_df = pd.concat([df0, df1], axis=1).stack()
return pivot_df
def get_shift_feature(input_df, value_col, periods, in_inning=True, aggfunc=np.median):
pivot_df = pd.pivot_table(input_df, index='subGameID', columns='outCount', values=value_col, aggfunc=aggfunc)
if in_inning:
dfs = []
for inning in range(9):
df0 = pivot_df.loc[:, [out+inning*6 for out in range(0,3)]].shift(periods, axis=1)
df1 = pivot_df.loc[:, [out+inning*6 for out in range(3,6)]].shift(periods, axis=1)
dfs += [df0, df1]
pivot_df = pd.concat(dfs, axis=1).stack()
else:
df0 = pivot_df.loc[:, [out+inning*6 for inning in range(9) for out in range(0,3)]].shift(periods, axis=1)
df1 = pivot_df.loc[:, [out+inning*6 for inning in range(9) for out in range(3,6)]].shift(periods, axis=1)
pivot_df = pd.concat([df0, df1], axis=1).stack()
return pivot_df
アンサンブルに関して
今回、学習データをテストテータに近づけるために、学習データからサンプリングを行うことをしています。そのため、サンプリングの偏りによって、overfitしてしまわないか?などが気になったので、複数のサンプリングの設定で学習データを生成して、予測結果を出していきました。結果的にはPublicにoverfitしてしまった形となってしまいました。
感想
初の金メダルという獲得というところまで行くことができました!
1位の方の解法を見ると、不均衡データのみを分類するモデルを作成して、精度を向上させるなどの取り組みがあるなど、解法も勉強になりました。
次回のコンペは画像系ということで、普段中々取り組めないものなので精進していこうと思います。
最後に、運営の皆様や、参加者の皆様に心より御礼申し上げます。