LoginSignup

This article is a Private article. Only a writer and users who know the URL can access it.
Please change open range to public in publish setting if you want to share this article with other users.

More than 5 years have passed since last update.

J リーグの観客動員数予測

Last updated at Posted at 2018-12-30

はじめに

本稿はSIGNATEにより開催されたJリーグの観客動員数予測のコンペティションに対する自分の取り組みを記すことを目的とする。

コンペティションの概要

このコンペティションは、2006 ~ 2016年のJ1リーグの試合情報 (開催スタジアム・天候・出場メンバー等) を学習データ、2017年および2018年前半シーズンのJ1リーグの試合情報をテストデータとして観客動員数を予測するモデルをつくり、その精度を競うものである。評価指数はRMSLEと定められている。詳細は上記のリンクを参照されたい。

前処理・データの観察

本コンペティションで最終的に優勝した方が開催中にチュートリアルを公開なさってくださったため、僕はそちら(このWebページ)を利用させていただいた。

自分なりに工夫した点

前処理

・キックオフの時間を時間帯別に3つに区切った

#kick_off_section

total_df['kick_off_section'] = np.arange(total_df.shape[0])
total_df['kick_off_section'][total_df.kick_off_time_hour < 12] = 0
total_df['kick_off_section'][(12 <= total_df.kick_off_time_hour)  & (total_df.kick_off_time_hour< 18)] = 1
total_df['kick_off_section'][18 <= total_df.kick_off_time_hour] = 2

・46種類もの天気を試合が開催されている時間にフォーカスして、['晴', '曇系', '雨系', '屋内', '試合中のみ雨', 'なんとなく雨']に分類した。

#weather

weathers = {'晴': 0, '曇': 1, '雨': 2,'雪': 2, '霧': 2, '屋内': 3}
rains = ['雨', '霧', '雪']
kick_off_section_df = total_df['kick_off_section']

list_ = []
count = 0
for (i, w) in enumerate(list(total_original.weather[:3825]) + ['晴' for i in range(18)]):
    if w in weathers:
        list_.append(weathers[w])
        continue
    if len(w.split('のち')) == 2:
        if (w.split('のち')[1] in rains) & (kick_off_section_df[i] == 1):
            list_.append(4)
            continue
        if (w.split('のち')[0] in rains) & (kick_off_section_df[i] == 0):
            list_.append(4)
            continue
    if len(w.split('のち'))  ==  3:
        if (w.split('のち')[2] in rains) & (kick_off_section_df[i] == 2):
            list_.append(4)
            continue
        if (w.split('のち')[1] in rains) & (kick_off_section_df[i] == 1):
            list_.append(4)
            continue
        if (w.split('のち')[0] in rains) & (kick_off_section_df[i] == 0):
            list_.append(4)
            continue
    for r in rains:
        if r in w:
            list_.append(5)
            count +=1
            break
    if count == 0:
        list_.append(1)
    else:
        count = 0

total_df['weather_condition'] = list_

weather_dums = pd.get_dummies(total_df['weather_condition'])
weather_dums.columns = ['晴', '曇系', '雨系', '屋内', '試合中のみ雨', 'なんとなく雨']
total_df = total_df.merge(right = weather_dums, how = 'left', left_on = total_df.index, right_on = weather_dums.index).drop(['key_0'] ,axis =1)

・各試合における得失点差を算出した。

#gap of points

team_score = pd.DataFrame(np.zeros([total_original.shape[0], len(list(teams))]).astype(int), columns = list(teams))
total_original_match = total_original.merge(right = match_df[['id', 'home_team_score', 'away_team_score']], how = "left", left_on = "id", right_on = "id")

for i in range(3825):
    if total_original_match.home_team_score[i] > total_original_match.away_team_score[i]:
        team_score.loc[i,[total_original_match.home_team[i]]] = 3 
    elif total_original_match.home_team_score[i] < total_original_match.away_team_score[i]:
        team_score.loc[i,[total_original_match.away_team[i]]] = 3 
    else:
        team_score.loc[i,[total_original_match.home_team[i]]] = 1
        team_score.loc[i,[total_original_match.away_team[i]]] = 1 
    if i == 0:
        continue
    for j in range(team_score.shape[1]):
            team_score.iloc[i, j] = team_score.iloc[i, j] + team_score.iloc[i-1, j]

list_ = []
for i in range(3825):
    list_.append(np.abs(team_score[total_original_match.home_team[i]][i]- team_score[total_original_match.away_team[i]][i]))
total_df["gap_of_points"] =  list_  + [0 for i in range(18)]

・試合出場選手それぞれが過去もしくはその年に日本代表選手に選ばれた回数の、全ての選手の合計値を求めた。ただしそのデータはこのウェブページから試行錯誤しながらスクレイピングしてdaihyou.csvファイルにまとめた。

#daihyou_num

daihyou = pd.read_csv('/Users/yoshizawarikuto/ai_study/j_league/daihyou.csv')
list_ = []
for y in daihyou.columns:
    list_.append(int(y))
daihyou.columns = list_

player_name_df = pd.DataFrame()
i=11
while(i>0):
    player_name_df['home_team_player'+str(i)] =[n.split(' ')[1] + n.split(' ')[2] if len(n.split(' ')) == 4 else n.split(' ')[1] for n in match_df['home_team_player' + str(i)].values]
    i-=1

i=1
while(i<12):
    player_name_df['away_team_player'+str(i)] =[n.split(' ')[1] + n.split(' ')[2] if len(n.split(' ')) == 4 else n.split(' ')[1] for n in match_df['away_team_player' + str(i)].values]
    i+=1

player_name_df.index = match_df.id

count_home = 0
count_away = 0
list_ = []
list_home=[]
list_away=[]
for i in range(player_name_df.shape[0]):
    for j in range(player_name_df.shape[1]):
        if player_name_df.iloc[i, j] in daihyou[1998].values:
            if j < 11:
                count_home += 1
            else:
                count_away +=1
        if player_name_df.iloc[i, j] in daihyou[2002].values:
            if j < 11:
                count_home += 1
            else:
                count_away +=1
        for y in [2006, 2010, 2014, 2018]:
            if player_name_df.iloc[i, j] in daihyou[y].values:
                if j < 11:
                    count_home += 1
                else:
                    count_away +=1
            if total_df.match_date_year[i] <= y:
                break
    list_home.append(count_home)
    list_away.append(count_away)
    list_.append(count_home + count_away)
    count_home = 0
    count_away = 0

total_df['daihyou_num'] = list_ + [0 for i in range(18)]

・カテゴリカルデータの中でも数値的な関係を持っているものはMinMaxScaling(偏微分等をしてパラメータ更新していくものの場合は更新幅の都合でデータを0~1の範囲にした方がいいのではないかと考えた)、そうではないカテゴリカルデータはダミー変数化、数値データはStandardScalingを施した。

#binarize
for col in ['section', 'round', 'match_date_month','match_date_dayofweek']:
    total_df = pd.concat([total_df,pd.get_dummies(total_df[col], prefix = col)], axis = 1)

#scaling
from sklearn.preprocessing import StandardScaler
scaler = StandardScaler()
all_train_X.index = range(all_train_X.shape[0])
for col in ['temperature', 'humidity', 'capacity', 'home_team_score', 'away_team_score', 'gap_of_points','daihyou_num']:
    scaled = pd.DataFrame(scaler.fit_transform(total_df[col].values.reshape(-1,1)), columns=[col + '_std'])
    total_df = pd.concat([total_df, scaled], axis = 1)

from sklearn.preprocessing import MinMaxScaler
scaler = MinMaxScaler()
for col in ['match_date_year', 'match_date_day', 'kick_off_time_hour', 'kick_off_time_minute']:
    scaled = pd.DataFrame(scaler.fit_transform(total_df[col].values.reshape(-1,1)), columns=[col + '_0_1'])
    total_df = pd.concat([total_df, scaled], axis = 1)

・試合が行われた場所もダミー変数で特徴量として加えた。チュートリアルではカテゴリカルデータとして省かれていたためである。

ven_dum_total = pd.get_dummies(total_original['venue'])
ven_dum_total.index = total_df.index
total_df = pd.concat([total_df, ven_dum_total], axis  = 1)

学習

・RandomRorestを用いて特徴量の重要度を算出し、その重要度が高かった順に特徴量をある個数分採用した。その個数は学習器に学習させた際にcross_validationのスコアが一番高く出る個数にした。学習器は色々な学習器やスタッキング、ブレンディングなど色々試した結果、lightgbm単体が一番良かった。パラメータチューニングもGridSearchでいくつか試してみたが、結果的にデフォルトを採用した。また、採用する特徴量は自分で特徴量を選択するよりも学習器に選択させた方が性能がよかった。

#total_df = total_df.dropna()
train_df = total_df.query("id < 19075")
train_df.loc[:, "attendance"] = target.values
train_df.drop(2479, inplace=True)
test_df = total_df.query("id >= 19075")
all_train_X = train_df.drop(["attendance"] , axis = 1)
all_train_y = np.log1p(train_df["attendance"])
test_X = test_df
from sklearn.model_selection import train_test_split
from sklearn.ensemble import RandomForestRegressor
train_X, val_X, train_y, val_y = train_test_split(all_train_X, all_train_y)
rfr = RandomForestRegressor(random_state=0)
rfr.fit(train_X, train_y)
train_pred = rfr.predict(train_X)
val_pred = rfr.predict(val_X)

print("train_score: {:1.5}".format(np.sqrt(mean_squared_error(train_y, train_pred))))
print("val_score: {:<.5}".format(np.sqrt(mean_squared_error(val_y, val_pred))))

sorted_imp = pd.DataFrame(rfr.feature_importances_, columns = ['importance']).sort_values(by = 'importance', ascending = False)

import lightgbm as gbm
from sklearn.model_selection import GridSearchCV

pick_index = sorted_imp.iloc[:270].index.values
pick_all_train_X = all_train_X.iloc[:,pick_index]
estimator = lgb.LGBMRegressor(random_state= 0)

param_grid = {
}

grid = GridSearchCV(estimator, param_grid, cv = 5, scoring='neg_mean_squared_error')
grid.fit(pick_all_train_X , all_train_y)

print(grid.best_params_)
np.sqrt(-grid.best_score_)

また、ブレンディングの一例を以下に示す。

#blender

xgb = xgboost.XGBRegressor(random_state=0)
ada = AdaBoostRegressor(random_state=0)
gbr = GradientBoostingRegressor(random_state=0)
extra=ExtraTreesRegressor(random_state=0)
svr = SVR()
lr = LinearRegression()
rfr = RandomForestRegressor(random_state=0)
#n_estimators=500, n_jobs=-1, random_state=2434

estimators = [xgb, svr, lr]
#各学習器の性能を知る
train_X, traintest_X, train_y, traintest_y = train_test_split(all_train_X, all_train_y, test_size = 0.1)
for est in estimators:
    est.fit(train_X, train_y)
    prediction = est.predict(traintest_X)
    print(np.sqrt(mean_squared_error(prediction, traintest_y)))


blender = xgboost.XGBRegressor(random_state = 0)


train_X_predictions = np.empty((len(all_train_X), len(estimators)), dtype = np.float32)

for index, estimator in enumerate(estimators):
    train_X_predictions[:, index] = estimator.predict(all_train_X)

scores = cross_val_score(blender, all_train_X, all_train_y, scoring="neg_mean_squared_error", cv = 5)
print(np.sqrt(-scores))

・また、RandomRorestを用いた特徴量の重要度をソートしたものは下図のようになった。ただし横軸に重要度をとった。
スクリーンショット 2018-12-30 14.01.30.png
capacityとcapacity_stdが共存しているが、これはcapacityに過学習しているとも、それだけcapacityが重要でありcapacityの影響を学習器に対して大きく取れるので良いと考えることもできると思う。idが4番目に重要な特徴量として捕らえられているのは少し不安であるが、試合の開催順に依っていると考えれば確かに頷ける。

本コンペティションの優秀者が行なっていたこと

・各試合のホームチームとアウェイチームの本拠地の距離を特徴量として加える。
・ホームチームの営業収入(集客力を表すかもしれない)を特徴量として加える。
・集客数は会場のキャパにより制限されるため、集客数と人気度を分けて算出し、それら二つをブレンディングして観客動員数を求める。または観客動員数ではなくて観客動員率を求める。
・勝ち点の差だけでなく、その時における各チームの順位も特徴量に加える。
・hyperoptというベイズ最適化のライブラリでパラメータチューニングをする。
・courseraやkaggleなどにあった似たコンペティション、先行研究から発想やソースコードを拾う。

感想

目的変数である観客動員数の構成要素、またはその構成要素の構成要素と論理的に分けて考えることで、具体的でより正しいクリティカルな分析ができることを学んだ。また、過去にあった似たようなものを利用することの重要性も学んだ。RandomForestで得た重要度をソートして特徴量を選択することは便利ではあるが、落とし穴があるかもしれない。

0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up