Jリーグの各クラブのElo Rateを算出してみた

この記事ではJリーグと他のリーグの比較はしていません。 あくまでJリーグ内で各クラブの比較になります。

イロレーティング

イロレーティング (Elo rating) とは、対戦型の競技(2人のプレイヤーまたは2つのチームが対戦して勝敗を決めるタイプの競技)において、相対評価で実力を表すために使われる指標の一つ。数学的裏付けのある最も著名なレーティングシステムである。

イロレーティングは、もともとチェスの実力を表すために考案されたものだが、様々な競技に応用されている。具体的には

国際チェス連盟の公式記録 日本アマチュア将棋連盟の公式記録 将棋や囲碁などのオンライン対局場 サッカーのFIFAランキング ラグビーなどの一部の競技団体のランキング 対戦型オンラインゲームのランキングやマッチング などでイロレーティング、あるいはイロレーティングを改変したレーティングシステムが採用されている。一部の競技では単にレーティングと呼ぶこともある。

なお、「イロ」とは、考案者であるアルパド・イロ(ハンガリー生まれのアメリカ人物理学者)に由来する。

FROM WIKI

イロレーティング - Wikipedia

ざっくり言ったら強さの相対評価指標で対戦相手同士の数値を比較し、期待勝率を求めることができます。 計算式はこんな感じらしいです。

 R_a = R_b + K \cdot K_r \cdot (W_r - W_e)

式に出てくる文字について

 R_a :計算後のレート

 R_b :計算前のレート

 K :加減値となる定数 リーグ戦とかでは30で設定されることが多いらしいです。

 K_r:点差による掛け率です。 サッカーなら1点差以内:1、2点差:1.5、3点差:1.75、4点差:1.875、以降N点差2+(N-3)/8とするそうです。

KとKrについてはWorld Football Elo Ratingsより

www.eloratings.net

 W_r: 勝敗結果を数値化したものです。

勝ち:1、引き分け:0.5、負け:0

 W_e:Ratingの差による期待勝率です。計算式は以下のようになっています。

 W_e = \dfrac {1}{1 + (10 ^  {- \frac {dir} {400}})}

dirはそれぞれ対戦相手とのレートの差となっています。

例えば、レートが1700のチームAとレートが1500のチームBが対戦した場合、  dir = 200となり、

 W_e = \dfrac {1}{1 + (10 ^  {- \frac {200} {400}})}  = \frac {1}{1 + (10 ^  {- \frac {1} {2}})} = 0.7597 となります。

つまり、チームAはチームBに対して勝率76%を期待でき、逆にチームBはチームAに対して24%の勝率が見込めると言うものです。

レートに差がない時はお互いに五分五分の勝率を期待できます。

これが期待勝率[tx:W_e]となります。

ちなみに、Elo Rateですが、対戦相手による得意不得意などの相性はないと言う前提で計算しています。 これが現実の試合に当てはまるかと言ったら微妙ですけどね。

まぁElo Rateの説明は以上として、実際にどんな感じで計測したかと結果を書きたいと思います。

実装

Jリーグの対戦成績を全部集めるの面倒だったので2021年〜2023年のデータを取得しました。

取得方法はこちらのブログの記事を参考にしてスクレイピングを行いました。 unhat-lab.com

スクレイピングしたデータはそれぞれdf_stats_j_year.csvで出力しました。

出力したファイルを使い、Elo Rateを求めていきたいと思います。

j1_2021_rs = pd.read_csv('df_stats_j1_2021.csv')
j1_2022_rs = pd.read_csv('df_stats_j1_2022.csv')
j1_2023_rs = pd.read_csv('df_stats_j1_2023.csv')
j2_2021_rs = pd.read_csv('df_stats_j2_2021.csv')
j2_2022_rs = pd.read_csv('df_stats_j2_2022.csv')
j2_2023_rs = pd.read_csv('df_stats_j2_2023.csv')
j3_2021_rs = pd.read_csv('df_stats_j3_2021.csv')
j3_2022_rs = pd.read_csv('df_stats_j3_2022.csv')
j3_2023_rs = pd.read_csv('df_stats_j3_2023.csv')

Elo Rateを算出するために、まずチーム名とレートを管理するteamテーブルと 試合結果を管理するresultテーブルを作成しました。

teamテーブル

#j1
team_j1_TB = j1_2021_rs.loc[:,['チーム名']].drop_duplicates()
team_j1_TB['No'] = range(1, len(team_j1_TB) + 1)
team_j1_TB = team_j1_TB.loc[:,['No','チーム名']]
team_j1_TB['Rate'] = 1500
team_j1_TB['Rate_max'] = 1500
team_j1_TB['Date'] = '2021-01-01'
team_j1_TB = team_j1_TB.rename(columns={'チーム名': 'Team_name'})

J2、J3においても同様の処理をしました。 Rateは現在のレート、Rate_maxはレートが最大となった時の数値。

Dateはレートが最大となった日を記録するための項目です。

Noは何チームあるかぱっと見でわかるようにしただけの項目です。 また、初期のレートですが、2021年時点で各カテゴリに所属しているチームに以下の値を設定しました。

J1:1500、J2:1400、J3:1300

そして、2021年時点ではjリーグに参加していない「いわきFC」「奈良クラブ」「FC大阪の」の3チームに関しては初期レートを1250で設定しました。

最終的には収束するらしいので初期値はなんでもいいらしいですが、この辺は完全に個人的感覚で設定しています。

new_team = pd.DataFrame({'No': ['58', '59', '60'],
                   'Team_name': ['いわきFC', '奈良クラブ', 'FC大阪'],
                   'Rate': [1250, 1250, 1250],
                   'Rate_max': [1250, 1250, 1250],
                   'Date':['2022-01-01','2023-01-01','2023-01-01']})
#new_team
team_j3_TB = team_j3_TB.append(new_team)
#結合
team_TB = pd.concat([team_j1_TB,team_j2_TB,team_j3_TB])
team_TB

結果、このようなテーブルが作成されました。

続いて、resultテーブルですが、欲しい情報はチーム名、対戦相手の名前、試合の日付、両チームの得点数ですので、 次のようにしてデータを結合しました。

スクレイピングしたデータでは第Ν節のAvsBとBvsAのデータが重複して入っているため、 重複を消す処理をしています。

また、時系列順にElo Rateが変化して欲しいので日付順でソートしています。

j_2021_rs = pd.concat([j1_2021_rs,j2_2021_rs,j3_2021_rs]).drop_duplicates(subset=['日付', '場所']).sort_values('日付')
j_2021_rs = j_2021_rs[['日付','チーム名','対戦相手','得点','失点']]
j_2021_rs = j_2021_rs.rename(columns={'日付':'Date','チーム名': 'Home_team','対戦相手':'Away_team','得点':'Home_Goal','失点':'Away_Goal'})

#2022
j_2022_rs = pd.concat([j1_2022_rs,j2_2022_rs,j3_2022_rs]).drop_duplicates(subset=['日付', '場所']).sort_values('日付')
j_2022_rs = j_2022_rs[['日付','チーム名','対戦相手','得点','失点']]
j_2022_rs = j_2022_rs.rename(columns={'日付':'Date','チーム名': 'Home_team','対戦相手':'Away_team','得点':'Home_Goal','失点':'Away_Goal'})
#2023
j_2023_rs = pd.concat([j1_2023_rs,j2_2023_rs,j3_2023_rs]).drop_duplicates(subset=['日付', '場所']).sort_values('日付')
j_2023_rs = j_2023_rs[['日付','チーム名','対戦相手','得点','失点']]
j_2023_rs = j_2023_rs.rename(columns={'日付':'Date','チーム名': 'Home_team','対戦相手':'Away_team','得点':'Home_Goal','失点':'Away_Goal'})
#2021~2023を結合
j_rs = pd.concat([j_2021_rs,j_2022_rs,j_2023_rs])

これで欲しいデータは揃ったので、あとは計算していくだけです。

Elo Rateについては冒頭の式に基づき、以下のように関数を作成しました。

def Elo_Rate(Rate_Home,Rate_Away,Goal_Home,Goal_Away,K = 30):
    
    #勝敗結果
    dif  = Goal_Home - Goal_Away
    
    if dif > 0:
        W = 1
    elif dif < 0:
        W = 0
    else :
        W = 0.5

    #点差による係数Krの算出
    dif_abs  = abs(Goal_Home - Goal_Away)
    
    if dif_abs <= 1:
        Kr = 1
    elif dif_abs == 2:
        Kr = 1.5
    elif dif_abs == 3:
        Kr = 1.75
    elif dif_abs == 4:
        Kr = 1.875
    else:
        Kr = 1.75 + ((dif_abs - 5)/16)
        
    #Ratingの差を求め,Weを計算
    dr = float(Rate_Home  - Rate_Away)
    
    We = 1 / (10 ** (-dr/400) + 1)
    
    Rating = K * Kr *(W - We)

    #Elo_Rateの算出
    Elo_Rate_H = round(Rate_Home + Rating,2)
    Elo_Rate_A = round(Rate_Away + Rating * (-1),2)
    
    return (Elo_Rate_H, Elo_Rate_A)

こちらの関数をもとにループさせていきます。 色々Kの値を試した結果、25が良さそうだったのでK=25で実行しています。

tqdmてのはプログレスバーを表示させるだけですので無くても実行に問題はありません。

team_TB = pd.concat([team_j1_TB,team_j2_TB,team_j3_TB])

for i in tqdm(range(len(j_rs))):
    K = 25
    
    Home_Team = j_rs.iat[i,1]
    Away_Team = j_rs.iat[i,2]
    Home_Goal = j_rs.iat[i,3]
    Away_Goal = j_rs.iat[i,4]
    Home_Rate_Pre = team_TB[team_TB['Team_name'] == Home_Team]['Rate']
    Home_Rate = float(Home_Rate_Pre.iloc[-1])
    Away_Rate_Pre = team_TB[team_TB['Team_name'] == Away_Team]['Rate']
    Away_Rate = float(Away_Rate_Pre.iloc[-1])
    
    #Elo_Rate callcurate
    Home_Rate_After  = Elo_Rate(Home_Rate,Away_Rate,Home_Goal,Away_Goal,K)[0] 
    Away_Rate_After = Elo_Rate(Home_Rate,Away_Rate,Home_Goal,Away_Goal,K)[1] 
    
    if Home_Rate_After >= team_TB.loc[team_TB['Team_name'] == Home_Team,'Rate_max'].iloc[-1]:
        team_TB.loc[team_TB['Team_name'] == Home_Team,'Rate_max'] = Home_Rate_After
        team_TB.loc[team_TB['Team_name'] == Home_Team,'Date'] = j_rs.iat[i,0]
    if Away_Rate_After >= team_TB.loc[team_TB['Team_name'] == Away_Team,'Rate_max'].iloc[-1]:
        team_TB.loc[team_TB['Team_name'] == Away_Team,'Rate_max'] = Away_Rate_After
        team_TB.loc[team_TB['Team_name'] == Away_Team,'Date'] = j_rs.iat[i,0]

    team_TB.loc[team_TB['Team_name'] == Home_Team,'Rate'] = Home_Rate_After
    team_TB.loc[team_TB['Team_name'] == Away_Team,'Rate'] = Away_Rate_After

team_TB['Ranking']=team_TB['Rate'].rank(ascending=False)
team_TB = team_TB[['Team_name','Rate','Rate_max','Date','Ranking']]
team_TB = team_TB.rename(columns={'Date':'Rate_max_date'})

結果は以下のようになりました。 個人的には割と妥当な気がしています。

気になる点は町田の順位が高いのを筆頭に、上のカテゴリで苦戦しているチームより下のカテゴリで勝っているチームの方が数字が上がりやすいところですね。

Jリーグは1部と2部の差がそこまであるリーグじゃないと思いますし、今年1位で昇格した新潟は夏に伊藤涼太郎が抜けたけど最終的には10位でしたのでJ2で首位のチームならまぁ上の方に来てもいいんじゃないかなと思います。

あとガンバが数字上だとめちゃくちゃ弱いですw。 降格した横浜FCより弱いみたいですね。 来シーズン大丈夫でしょうか。

Rateの最大値で見ると2023年7月のマリノス、2021年10月の川崎、2023年最終節の神戸がTOP3みたいでこちらも個人的には川崎のが上だろと思いましたがまぁ数値的にほぼ差がないので納得てところです。