論理の流刑地

地獄の底を、爆笑しながら闊歩する

Masseyレーティングで各チームの前半↔後半のレーティング差を可視化してみる(小中本の実践)

気分転換でしかない記事

Introduction

先日名著『科学で迫る勝敗の法則』のメモをとった
ronri-rukeichi.hatenablog.com


感想は上の記事にまとめたが、Massey Ratingの項(4章)をよんでいて、
いくつか試したいことがあったのでRで分析してみる

具体的には、

  • 2023年の名古屋グランパスの問題点として「交代選手が点をとれないこと」があげられていたのだが、前半45分だけでの得点差と後半45分だけの得点差を用いて別々にレーティングを算出したらやはり名古屋は後半のレーティングのほうが低いのか

の、検証的なことをしたい。

Massey Ratingとは(小中本より)

Massey Ratingとは「チームのレーティング差が得点差に対応する」というアイディアをそのまま数式に表現したようなシンプルな手法であり、

チームi, j のレーティングをr_i , r_jとすると、その2チームが対戦したある試合の得点差y_kが誤差を伴って
r_i - r_j + \epsilon_k = y_k
といった形であらわれるという発想の手法である。

詳しくは該当書のpp.154-155を参照してほしいが、
各チームが戦った時のレーティングの差は1, 0 ,-1のいずれかを成分とするK×Rのマッチング行列
(上式でいうところのチームiに1, チームjに-1を割り当ててあと関係ないチームのとこは0となっている行列)
とレーティングのベクトルr_1,r_2,,,,r_Rの積で表せて(左辺)、
それが実際の得点差のベクトルYと誤差ベクトルεの差に一致するので、マッチング(というかインデックス)行列をXとすると

\boldsymbol{Xr} = \boldsymbol{y}- \boldsymbol{\epsilon}

といった形でかける。これは統計を触ったことのある人なら線形回帰を習う時とかにめっちゃよくみたことのある形であって、
誤差二乗和を最小化するようなレーティング(のベクトル)rは、
\boldsymbol{r} = \boldsymbol{ (X^{\top}X)^{-1}Xy }
で求められる。
※統計ソフトなら普通の線形回帰のコマンドとか使ってもいい


なお、Matlabが使える人は、著者である小中先生自身がコードを公開しているらしいので、そちらをみるといい



データ・関数の準備

準備といってもまぁ使うのは得失点(とシュート/被シュート)のデータだけであるが、
(回帰分析的な語法でいえば)「説明変数」がマッチング行列で、それに回帰係数=レーティングが掛かって出てくるのが、「被説明変数」たる得点差という発想なので

  1. 各チームにインデックスを割り振ったうえで
  2. 各試合の結果(例えば名古屋2-2G大阪、みたいな)に対応するインデックス行列と、得点差のベクトルをつくる
  3. 得点差にインデックス行列を回帰して、レーティングを得る

という手順になる

データ構造

手元にあるデータは上のような構造をしていて、試合×クラブ、すなわち34×18=612のレコードがあるのでひとつの試合について重複データがふたつずつあるので、そこらへんも注意しつつ以下のような関数を組む

関数詳細(R, クリックで展開)

Calc_Massey <- function( dta,score_i = "Goal_For", score_j = "Goal_Against" ){
  
  ## ClubのIndexを付与
  
  clb <- dta[["Club"]] %>% unique() %>% sort() 
  
  dfClub <- data.frame( Club = clb , 
                        Index = 1:length( clb))
  
  ## 得点差ベクトルを得る
  score_diff <- dta[[score_i]] - dta[[score_j]]
  
  ## 自チーム/敵チームをインデックスに変換
  idx_i <-  data.frame( Club = dta$Club) %>% left_join( dfClub , by = "Club") %>>% (Index)
  idx_j <-  data.frame( Club = dta$Opponent) %>% left_join( dfClub , by = "Club") %>>% (Index)
  
  ## インデックスと得点差, 日付だけ
  dfMassey <- data.frame( i = idx_i,
                          j = idx_j, 
                          s_i = dta[[score_i]] ,
                          s_j = dta[[score_j]],
                          ScoreDiff = score_diff,
                          Date = dta$Date
                          )
  
  dfMassey <- dfMassey %>%
    rowwise( ) %>% mutate( idx_min = min( i , j ), idx_max= max( i , j)) %>%
    mutate(ID =  paste(idx_min, idx_max , Date , sep = "_")) %>%  ungroup %>% as.data.frame() %>% 
    mutate( s_min =  if_else( idx_min == i , s_i , s_j ),
            s_max =  if_else( idx_max == j , s_j , s_i ))
  
  
  ## 重複を除く
  
  dfCalc <- dfMassey %>% select( Date , idx_min, idx_max , s_min , s_max) %>% unique() %>%
   mutate( y = s_min - s_max )
  
  
  ## インデックス行列を得る
  index_l <- list( )
  for( k in 1:nrow( dfCalc)){
   idx_k <- rep( 0 , nrow( dfClub)) #初期化
   idx_k[dfCalc[ k, "idx_min"]] <- 1
   idx_k[dfCalc[ k, "idx_max"]] <- -1
   
   index_l[[k]] <- idx_k
  } #for ループ
  
  
  MatMatch <- do.call( what = rbind , args = index_l)
  
  
  dfMatch <- as.data.frame( MatMatch) 
  colnames( dfMatch) <- paste0( "r_", 1:ncol(dfMatch))

  
  dfEst <- dfMatch
  dfEst$y  <- dfCalc$y
  
  ## 回帰をまわしてスコアを出る
  
  lm_res <- lm(y ~ 0 +r_1 + r_2 + r_3 + r_4 + r_5 + r_6 + r_7 + r_8 + r_9 + r_10 + r_11 + r_12 + r_13 + r_14 + r_15 + r_16 + r_17 , data= dfEst )
  
  #回帰係数を得る(得たあと中心化)
  coef_raw <- lm_res$coefficients
  coef_cons <- -1 * (mean(c( coef_raw , 0)))
  coef_mod <- coef_cons + c( coef_raw, 0)
  
  dfRating <- data.frame(Club = dfClub$Club, Rating =coef_mod  )
  # 
  # return( list( Index = dfClub,
  #               Data = dfMassey,
  #               Calc = dfCalc, 
  #               Match = dfMatch ,
  #               Est = dfEst,
  #               Coef = coef_mod))
  rownames( dfRating) <- NULL
  
  return( dfRating )
  
} #function


この関数で計算すると、だいたい得失点差に沿ったランキングがでる

res1 <- Calc_Massey( dta = j1_stat23)

分析:前後半の得点差からRatingを算出して、くらべてみる

ということで早速前半/後半の得失点のデータだけ使ってMassey Ratingを算出してみる

## 前半のゴール差をもとに算出
res_1st <- Calc_Massey(dta = j1_stat23 , score_i = "Goal_For_1st", score_j= "Goal_Against_1st")

## 後半のゴール差をもとに算出
res_2nd <-  Calc_Massey(dta = j1_stat23 , score_i = "Goal_For_2nd", score_j= "Goal_Against_2nd")

このようにして得られたデータをさっきの90分トータルでのRatingとあわせると、以下のような感じになる

Club Rating_Total Rating_1st Rating_2nd
c-os 0.139 0.111 0.028
fctk -0.111 0 -0.111
fuku -0.167 -0.222 0.056
g-os -0.639 -0.167 -0.472
hiro 0.389 -0.083 0.472
ka-f 0.167 -0.167 0.333
kasm 0.25 0.25 0
kasw -0.389 0.083 -0.472
kobe 0.861 0.278 0.583
kyot -0.139 -0.028 -0.111
nago 0.139 0.139 0
niig -0.111 -0.25 0.139
sapp -0.139 0.139 -0.278
shon -0.444 0.028 -0.472
tosu -0.111 -0.167 0.056
uraw 0.417 0.083 0.333
y-fc -0.75 -0.333 -0.417
y-fm 0.639 0.306 0.333

前半データに基づくレーティング得点+後半データに基づくレーティング得点=90分でのデータを使ったレーティング得点になっている。

名古屋(nago)は、やはり前半のスコア(1.139)に比べて後半のスコア(0.0)のほうが低い。リーグ内順位でいうと前半45分はレーティング4位だけど後半45分の名古屋は10位だ。
ほかに面白いところをあげると、

  • 後半大きく崩れる柏(kasw, 前半0.083, 後半-0.472)
  • 前半苦戦するが後半に盛り返す川崎(ka-f, 前半-0.167, 後半0.333)

などがある。優勝した神戸は当然前後半ともに良い数値だけど、後半とくに爆発しているようだ。

図示すると以下のような感じである

(軸の都合で45°にみえないけど)破線がX=Yのいわゆる45度線で、雑駁に言えば
これより下に位置するチームは前半型、破線より上に位置するチームは後半型といえる。
(また、当然だが左下より右上のほうがよい成績になる)

こうみると、名古屋は確かに前半にくらべて後半45分落ち込むほうのチームではあったけど、札幌・柏・湘南あたりほど極端ではなかった、という感じだろうか。


Enjoy!!