論理の流刑地

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

StatsBombをRで遊ぶ

個人的な備忘録でしかないメモ。

Introduction

先日saenai氏が、noteで以下のような記事を公開していた。
note.com

この記事内の最後にも少し触れられている通り、Jにおいてはこの種の詳細なイベントデータはオープンな形で公開されていないのが現状である。
したがって、主たる関心がJリーグにある自分にとっては、すぐに役立つ種の情報ではないのだが、いっぽうでJリーグに関して現在利用可能な情報からは考えられないような幅広い変数を含むデータというのはそれ自体魅力的だ。

あまりStatsbombをRで使うことに関しての日本語記事は多くは見当たらないため、
saenaiさんの記事内容を理解しつつ、同じような処理をRで行うにはどうすればいいのか?ということ*1を書き記しておくのが本記事である。

<参考URL>

Rでも実現したい処理工程の把握・書き出し

まず、saenai氏の記事(およびハンズオン用code)において行われていることを一覧化する。
※URLはsaenai氏作成のハンズオンへのリンク。

  1. データの取得・整形(URL)
    1. 大会情報(JSONデータ)の取得・整形
    2. 試合情報の取得
    3. ラインナップの取得
    4. イベントデータの取得
  2. イベントデータの基礎集計と可視化(URL)
    1. チーム別集計(例:パス本数)
    2. 個人別集計(例:タッチ位置平均)
    3. 時間帯ごとにパス本数を集計 ※本記事では割愛
  3. イベントデータのピッチ上への可視化(URL)
    1. 選手ごとのヒートマップの可視化 ※本記事では割愛
    2. プレッシャーをかけた位置のヒートマップを可視化 
    3. パスの流れの可視化 ※本記事では割愛
    4. パスマップ図の可視化
    5. ショットマップの可視化

だいたい、こんなところだろうか。なかなか夢膨らむ内容である。
以下、saenai様のハンズオン内のコードや図表等を参照しつつ、同一の処理をRで実装していく。

データの取得・整形

JSON形式でデータは公開されており(URL)、大まかに分けて
①大会情報データ②試合情報データ③イベントデータ④ラインナップデータ、の4つの種類がある。
JSONデータをラクに扱うために、jsonliteパッケージをインストールしときましょう。

大会情報の取得・整形

以下のような形で、まずは大会情報データを取得したい。

f:id:ronri_rukeichi:20201214182948p:plain
画像出所:saenai氏のハンズオン資料

以下のようにjsonlite::read_json()をつかってからJSON形式でまずデータを取得する

compe_url <- "https://raw.githubusercontent.com/statsbomb/open-data/master/data/competitions.json"
compe_json <- read_json( compe_url)

read_json()はJSONの階層構造をリストとして取得してくる。
これをデータフレームに変換する。

compe_row_l <- lapply( compe_json , function(rw){
  return(as.data.frame( rw  , stringsAsFactors =F))
})
compe_df <- do.call( rbind , compe_row_l)
head( compe_df,2)
# competition_id season_id country_name competition_name
# 1             16         4       Europe Champions League
# 2             16         1       Europe Champions League
# competition_gender season_name              match_updated
# 1               male   2018/2019 2020-10-25T12:33:27.855343
# 2               male   2017/2018           2020-07-29T05:00
# match_available
# 1 2020-10-25T12:33:27.855343
# 2           2020-07-29T05:00

簡単ですね。

試合情報の取得・整形

つぎにcompetitionやseasonのIDをkeyとして、試合情報を取得してくる処理を実装する。
具体的に「試合情報」とは何かというと、大会/シーズン/ホームチーム名/アウェイチーム名/開催地/大会ステージ、などの情報をさしている。
(元データには実は両チームの監督名や、レフェリーの名前などの情報もあるのだが、saenai氏同様に上の項目だけをデータフレーム化する)

saenai神のコードをみると、competitionは必須で、seasonに関しては指定しなければ該当のcompetitionの全seasonをとってくる、という処理(を行う関数)を書いているようなので、そうする。

getMatchData <- function( compe_id , season_id=NULL ,compe_df =NULL ){

##-- 内部関数, 生JSONデータのURLを生成する---##
makeMatchURL <- function( compe_id ,season_id ){
  base_url <- "https://raw.githubusercontent.com/statsbomb/open-data/master/data/matches/"
  ret_url <- paste0( base_url, compe_id, "/", season_id,".json")
  return( ret_url)
} #func

##-- 内部関数, 生JSONデータからデータフレーム形式へ変換する--##
fetch_mInfo <- function( match_url){
  info_list <- jsonlite::read_json(match_url)
  info_row_l  <- lapply( info_list , function( wc_row){
    wc_row$away_team$managers <- wc_row$away_team$managers[[1]]
    wc_row$home_team$managers <- wc_row$home_team$managers[[1]]
    var_list <- list()
    rapply(wc_row , function(wr){ 
      var_list <<- c(var_list ,wr) 
    })#
    tst_df <- as.data.frame( var_list, stringsAsFactors=F)
    colnames(tst_df) <- names( unlist( wc_row))
    return( tst_df)
  }) #lapply
  
  return(do.call( dplyr::bind_rows , args=info_row_l ))
} #fetch_mInfo

if( is.null(season_id) && !is.null(compe_df)){
  season_ids <- (dplyr::filter( compe_df , competition_id == compe_id ))[,"season_id"]
  match_df_l <- lapply(season_ids , function( season_id){
    target_url <- makeMatchURL(compe_id , season_id)
    ret_df <- fetch_mInfo(target_url)
    return(ret_df)
  }) #lapply
  match_df <- do.call( dplyr::bind_rows, match_df_l)
  
}else{
  target_url <- makeMatchURL( compe_id ,season_id)
  match_df <- fetch_mInfo(target_url)  
}

return( match_df)
} #function 

あまり難しいことはやっていないけれど、とりあえずURLを生成する内部関数(makeMatchURL)と
URLから試合情報をdata.frame形式で取得する関数(fetch_mInfo)をつくって、season_idが指定されなかった場合はループ処理をしている、という感じの内容。
たとえばバルサの過去試合を取得するのならば、以下の感じで。

barca_df <-  getMatchData( compe_id =11, compe_df=compe_df)

dplyr::glimpse(barca_df)
# Rows: 485
# Columns: 48
# $ match_id                        <int> 303421, 303493, 303516, 303680...
# $ match_date                      <chr> "2020-07-19", "2020-06-23", "2...
# $ kick_off                        <chr> "17:00:00.000", "22:00:00.000"...
# $ competition.competition_id      <int> 11, 11, 11, 11, 11, 11, 11, 11...
# $ competition.country_name        <chr> "Spain", "Spain", "Spain", "Sp...
# $ competition.competition_name    <chr> "La Liga", "La Liga", "La Liga...
# $ season.season_id                <int> 42, 42, 42, 42, 42, 42, 42, 42...
# $ season.season_name              <chr> "2019/2020", "2019/2020", "201...
# $ home_team.home_team_id          <int> 206, 217, 209, 901, 217, 207, ...
# $ home_team.home_team_name        <chr> "Deportivo Alavés", "Barcelona...
# $ home_team.home_team_gender      <chr> "male", "mal

barca_df <-  getMatchData( compe_id =11, season_id = 4 , compe_df=compe_df)

ラインナップの取得

次はlineupの取得である。例として16/17シーズンのクラシコ@カンプノウのデータ(URL)から取得してみよう。
取得してくる項目は、saenaiさまの元コードと同じく、選手ID/選手名/背番号/スタメンかどうか/チームID/チーム名、とする。

############------------====lineup情報の取得====---------############
getLineUp <- function( match_id ){
  json_url <- paste0("https://raw.githubusercontent.com/statsbomb/open-data/master/data/lineups/",match_id ,".json")
  json_list <- read_json( json_url)
  ## 内部関数:各チームの情報をまとめてData Frame化 ##
  getTeamData <- function( t_list){
    t_id <- t_list$team_id
    t_name <- t_list$team_name
    player_info <- t_list$lineup
    player_df_l <- lapply( player_info , function(player_row){
      ret_row <- list()
      rapply( player_row ,function(x){
        ret_row <<- c( ret_row , x) #listに順次追加をしていく。
      })#rapply
      ret_names <-  gsub( "\\.","_", names(unlist( player_row)))
      ret_df <- as.data.frame( ret_row, stringsAsFactors=F)
      colnames(ret_df) <- ret_names
      return( ret_df)
    })## lapply
    
    player_df <- do.call( dplyr::bind_rows ,args = player_df_l )
    player_df$team_id <- t_id
    player_df$team_name <- t_name
    player_df <- dplyr::select( player_df , team_id, team_name , player_id:country_name)
    return( player_df)
  } #function
  
#Home/Awayそれぞれの情報を処理してタテにつなげる
  team_df <- do.call( rbind , lapply( json_list , function(team_json){
    return( getTeamData(team_json))
  })) #
  return(team_df)
} #function

#16/17のクラシコのlineupの取得
clsc_df <- getLineUp( 267076)

head(clsc_df, 2)
# team_id   team_name player_id                    player_name
# 1     220 Real Madrid      3163             Mariano Diaz Mejia
# 2     220 Real Madrid      4926 Francisco Roman Alarcon Suarez
# player_nickname jersey_number country_id       country_name
# 1    Mariano Diaz            18         64 Dominican Republic
# 2            Isco            22        214              Spain

あまり処理自体について説明することはないけれど、一つだけ付言すると、
read_jsonするとnested list形式で取得されるため、これをflatにしてdata.frameにするためにrapply()を使っている。
※「<<-」を使っていてあまり綺麗じゃないんだけど、以下のStack Overflowを参考にした。
stackoverflow.com

unlist()すると、強制的に型変換が行われてしまい、integerがcharacterとかになってしまうのを嫌った形。
R愛してるけど、ここらへんもっと融通利かないかなぁ。

イベントデータの取得

さいごに、同じ試合を対象としてイベントデータ(URL)を整形して取得してみよう。
試合のあらゆる事象が網羅されているので、当たり前だがデータ量がすんごいことになっている。

とりあえずread_json()して、構造を見てみよう。

evnt_url <- "https://raw.githubusercontent.com/statsbomb/open-data/master/data/events/267076.json"
evnt_json <- read_json( evnt_url)

イベントの種類を$event$nameでアクセスして集計してみると、以下のようになる

f:id:ronri_rukeichi:20201215105639p:plain
イベント一覧@16/17 クラシコ

意外とイベントの種類自体は少ないことがわかる。結構見慣れない指標もある
頭を悩ませるのが、イベントの種類によってかなりデータの形式(どういう変数を含むのか)が違ってくること。
だから、すべてのデータをひとつのdata frameで保持しようとせずに

  • ID/イベントの種類/時間情報のみを保持する共通テーブル
  • 各イベントごとの詳細情報を格納するテーブル

にわけて、情報を格納していくことにしよう。


まず、以下のような関数を実装する。

############------------====Event情報の取得====---------############
getEventInfo <- function( match_id){
  evnt_url <- paste0("https://raw.githubusercontent.com/statsbomb/open-data/master/data/events/", match_id,".json")
  evnt_json <- read_json( evnt_url)
  
  ### 共通DBをつくる ###
  base_info_l <- lapply( evnt_json, function( evnt_row){
    eve_id <- evnt_row$id
    eve_idx <- evnt_row$index
    eve_period <- evnt_row$period
    eve_ts <- evnt_row$timestamp
    eve_min <- evnt_row$minute
    eve_sec <- evnt_row$second
    eve_type_id <- evnt_row$type$id
    eve_type_name <- evnt_row$type$name
    eve_row <- data.frame(event_ID=eve_id , Index = eve_idx , Period = eve_period , Minutes = eve_min , Seconds = eve_sec , TimeStamp= eve_ts, Type_ID = eve_type_id , Type_Name=eve_type_name, stringsAsFactors = F)
    return( eve_row)
  }) #base_info_l
  base_df <- do.call( dplyr::bind_rows, args = base_info_l)
  
  
  ### Eventの種類ごとのDFをつくる ###
  eve_type <- dplyr::distinct( dplyr::select( base_df , Type_Name , Type_ID))
  eve_df_l <- list()
  
  for( i in seq_along( eve_type$Type_ID)){
    #JSONから生成したリストのうち、同じ種類のイベントのものだけ取得する
    tgt_type <- eve_type$Type_ID[i]
    tgt_idx <- which( base_df$Type_ID == tgt_type)
    tgt_evnt <- evnt_json[tgt_idx]
    
    #イベントごとのDBを生成する
    tgt_evnt_l <- lapply( tgt_evnt  , function(tgt_row){
      ret_row <- list()
      rapply(tgt_row , function(x){
        ret_row <<- c( ret_row, x)
      }) #rapply
      
      ret_row <- as.data.frame( ret_row , stringsAsFactors=F)
      suppressMessages( colnames( ret_row) <- gsub( "\\.","_", names(unlist(tgt_row ))))
      return( ret_row)
    }) #lapply
    suppressMessages(
    tgt_evnt_df <-  do.call(dplyr::bind_rows, args = tgt_evnt_l)
    )
    colnames( tgt_evnt_df) <- gsub( "\\.\\.\\.","_No",colnames(tgt_evnt_df))
    eve_df_l[[gsub(" " , "_" ,eve_type$Type_Name[i] )]] <- tgt_evnt_df
  }# for

  ret_list <- list( Base = base_df, Event= eve_df_l)
  return(ret_list)
}#--func


以下のように用いる。

eve_df <- getEventInfo(267076)

head(eve_df$Base , 2)
# event_ID Index Period Minutes Seconds
# 1 29a5ab71-0bda-4291-a084-be14138bbe20     1      1       0       0
# 2 25bdcf3e-3eef-4d06-82a0-60bdae6af6ae     2      1       0       0
# TimeStamp Type_ID   Type_Name
# 1 00:00:00.000      35 Starting XI
# 2 00:00:00.000      35 Starting XI

head(eve_df$Event$Pass[,1:20],2)
# id index period    timestamp minute
# 1 ed0d68b9-f420-473b-8cb4-1dbb206ab2ad     5      1 00:00:00.233      0
# 2 adf4c9ad-a749-4f22-883d-1973430e653b     8      1 00:00:03.850      0
# second type_id type_name possession possession_team_id
# 1      0      30      Pass          2                220
# 2      3      30      Pass          2                220
# possession_team_name play_pattern_id play_pattern_name team_id
# 1          Real Madrid               9     From Kick Off     220
# 2          Real Madrid               9     From Kick Off     220
# team_name player_id    player_name position_id     position_name
# 1 Real Madrid     19677  Karim Benzema          23    Center Forward
# 2 Real Madrid      5485 Raphael Varane           3 Right Center Back
# location1
# 1      61.0
# 2      40.2

イベントの各項目の値の詳細は公式のドキュメントを参照(URL)。
これでデータの取得処理は実装し終えた(難しいことはやってないけど長かった...)ので、やっとデータの分析・可視化にうつることができる。

イベントデータの基礎集計と可視化

必要なデータが
saenai氏がハンズオンで行っている処理をRで再現してみる。
ここからは、saneai氏とおなじく10/11のカンプノウでのエル・クラシコ(ペップ vs モウリーニョの時代)のデータを使っていく

#対象試合のMatch IDを指定
lu_df <-  getLineUp(69299)
evnt_df <-  getEventInfo(69299)

チーム別集計(例:パス本数)

f:id:ronri_rukeichi:20201215221746p:plain
チーム別パス集計(saenai氏のハンズオンより)

saenai氏はチームごとに、上のような集計を行っているので、再現してみよう。
公式のdocをみると、以下のようにoutcomeの値の設定がなされている。

f:id:ronri_rukeichi:20201215222151p:plain
パスのOutcome定義(StatsBomb公式)

"Complete"がねーじゃん..って思うけどこれ以外のパスはすべて成功とみなしてとりあえず扱うこととする。
ということで、上の手順で取得したデータをもとに集計を試みる

pass_info <- evnt_df$Event$Pass
pass_info <- dplyr::mutate( pass_info , Pass_Result = as.character(case_when(
  is.na(pass_outcome_id) ~ "Complete",
  pass_outcome_id == 9 ~ "Incomplete",
  pass_outcome_id %in% 74:75 ~ "Out",
  pass_outcome_id == 76 ~ "Offside",
  TRUE ~ as.character(NA)
)))
pass_info<-  dplyr::left_join(pass_info  , dplyr::select(  lu_df, player_id ,  TeamName_byP= team_name), by ="player_id")  
xtabs(~ TeamName_byP + Pass_Result , data = pass_info)
f:id:ronri_rukeichi:20201215225457p:plain
パス数のチーム別集計結果

ちゃんとsaenai氏の表と一致している。

個人別集計(例:タッチ位置平均)

f:id:ronri_rukeichi:20201215230322p:plain
タッチ位置平均(出所:saenai氏のハンズオン資料)

StatsBombのデータのウレシイところは、位置情報についても事細かな情報を把捉できる点にもある。
選手ごとのタッチ位置の平均を算出している上のsaenai神の表を再現できるか、試みる。
ハンズオンでは、どのイベントを対象としたのがか書いていないので、location情報が取得できるイベントをすべて対象する

  • statsbombのデータは、ピッチで言うとcoordinates_xが縦、coordinates_yが横を示している
  • xとyが逆やないとも思うけれど、基本的にピッチをみるときは左右にゴールがある横視点が基本
  • またその数字はm単位でなく、0-100に変換されている

とsaenai氏のハンズオンにはあるけれど、こっちの取得したデータだとX座標は0~120 , Y座標は0~80のままであるので、それを変換しなければならない。
ちなみに上記の公式ドキュメントのAppendix 2には、座標系の解説があるけれども、これも特に変換はしていない(なぜだ?)

f:id:ronri_rukeichi:20201215234150p:plain
StatsBomb公式ドキュメントのAppendix2より

......結果から話すと悲しいことに数値は合わなかった。
おそらく原因をみるにはpythonのStatsbombパッケージの内部処理まで見る必要があるけど、そこまでやる気力はないので挫折。かなしい。

イベントデータのピッチ上への可視化

これも楽しい作業。
ピッチの描画や座標表現に関してはggsoccerパッケージをつかっていく。
名前から察することができるだろうが、ggplotを利用したパッケージであり、いくつかの代表的なfootballのデータの座標系に対応してピッチを描画することができる

以下のように使う。

gg_pitch1 <- ggplot( data = barca_coord , mapping=aes( x=X2 , y =Y2, label= player_nickname)) 
+ annotate_pitch(fill="darkolivegreen1", dimensions=pitch_statsbomb) + theme_pitch() 
+ geom_point( shape= 18 , fill = "midnightblue") + geom_text_repel( size = 3.5, family = "Meiryo" , color = "firebrick4")
+ direction_label(x_label=60)
f:id:ronri_rukeichi:20201216104752p:plain:w920
ggsoccer()による描画結果

annotate_pitch()でピッチを描画(dimensionに適切な座標系を指定)し、theme_pitch()でいらない軸や目盛りを消している。
direction_labelで攻撃方向を示す。

プレッシャーをかけた位置のヒートマップを可視化

f:id:ronri_rukeichi:20201216111402p:plain
Real Madridのプレッシャーヒートマップ(出所:Saenai氏のハンズオン資料)

上のような、各チームのプレッシャーの位置分布の可視化を実装する。
ヒートマップを使うにはgeom_tile()が便利である。
◆geom_tile()に関しての参考URL

以下のような処理で、同様のヒートマップを実装できる


#プレッシャー位置の取得
press_df <- evnt_df$Event$Pressure
press_Real <- dplyr::filter(press_df,team_name == "Real Madrid")

#縦6区画、横5区画への分割
h_sep_l <- lapply( 20* 0:5 , function(x){c( x,x+20)})
w_sep_l <-  lapply( 16* 0:4 , function(x){c( x,(x+16))})

numToArea <- function(x , area_l){
  ret_v <- c()
  for( i in seq_along(x)){
    x_i <- x[i]
    if(is.na(x_i)){
      ret_v <- c(ret_v , as.numeric(NA))
    }else{
    cnt <- 0
    flag <- FALSE
    lapply( area_l , function(area){
      cnt <<- cnt + 1
      if( x_i >= area[1] && x_i < area[2]){
        ret_v <<- c( ret_v, cnt)
        flag <<- TRUE
      }
    })# lapply
    if(!flag){ ret_v <- c(ret_v , as.numeric(NA))} #if
    
    } #if
  }#for
  return( ret_v)
} #func

press_Real$Press_AreaX <- numToArea( press_Real$location1 , h_sep_l)
press_Real$Press_AreaY <- numToArea( press_Real$location2, w_sep_l)
press_Real <- dplyr::mutate( press_Real , Press_AreaXY=interaction( Press_AreaX, Press_AreaY,sep="×"))

press_density <-  table(press_Real$Press_AreaXY) /sum(table(press_Real$Press_AreaXY))
dense_df <- data.frame( Density = as.numeric(press_density), Area = names( press_density))
xy_coord <- expand.grid( 1:6 , 1:5)
xy_coord[,1] <- xy_coord[,1]*20-10
xy_coord[,2] <- xy_coord[,2]*16 -8
dense_df  <- cbind( dense_df , xy_coord)
colnames( xy_coord) <-  paste0( c("X","Y"), "2")

press_Real <- dplyr::left_join( press_Real, dense_df , by = c("Press_AreaXY"="Area"))
press_Real$Press_Percent <- press_Real$Density * 100

#第一段階:プレス点のみ
gg_pressR <- ggplot( data = press_Real , mapping=aes( x = location1 , y=80- location2 )) +annotate_pitch(dimensions=pitch_statsbomb , fill="grey") + theme_pitch() + direction_label(x_label=60) + geom_point( color="white", shape=20 ) + ggtitle( "Pressure Map(Real Madrid @2010/11 El Clasico)") +theme(plot.title = element_text(hjust = 0.5))

#第二段階:ヒートマップ
gg_pressR2 <- gg_pressR + geom_tile(data = dense_df , mapping=aes( x= X2 , y =80-Y2, fill=Density*100, width= 20 , height = 16) ,alpha=0.5) + scale_fill_gradientn(colours=c("cyan","brown4"),name="Percentage") +geom_text(data = dense_df , mapping=aes( x= X2 , y =80-Y2 ,label=  paste0(round(Density*100 , 1),"%")) )

以下のように、saenai氏と同じような図が可視化できている。

f:id:ronri_rukeichi:20201216154221p:plain
Real Madridのプレッシャー位置のヒートマップ(データ出所:StatsBomb)

いやー面白い。

パスマップ図の可視化

f:id:ronri_rukeichi:20201216183957p:plain
パスネットワーク図(出所:saenai氏のハンズオン資料)

次は上のようなパスのネットワーク図を描いてみよう。
上の図の完全な再現をめざすではなく、以下のような考え方で簡易的に描いてみる。

  • 各ポジションの平均位置は、パスを出すイベント("Pass")に含まれている出し手/受け手の位置情報を使って取得する。
  • ポジションを表わす点の大きさは、(パスを出した回数+受けた回数)÷2とする
  • ポジション間をつなぐ線は(どちらがパスの出し手/受け手を区別せず)パスのやりとりの総数に比例する

とりあえず、データを可視化しやすいように取得・整形する

lu_df <-  getLineUp(69299)
evnt_df <-  getEventInfo(69299)

library( pipeR)
pass_df <- evnt_df$Event$Pass
rcv_df <- evnt_df$Event[["Ball_Receipt*"]]
pass_df$Team_byP <- lu_df[match(pass_df$player_id, lu_df$player_id),"team_name"]
position_info <-  dplyr::distinct(dplyr::select(pass_df , position_id , position_name ,player_name , player_id,Team_byP ))
pass_df$recipient_position <-  position_info[ match( pass_df$pass_recipient_id , position_info$player_id), "position_name"] #出し手のポジションを取得する
pass_df %>>%  dplyr::mutate( completed = if_else( is.na( pass_outcome_name), TRUE , FALSE) ) -> pass_df
pass_Real <- dplyr::filter( pass_df, Team_byP == "Real Madrid")
pass_Barca <- dplyr::filter( pass_df, Team_byP == "Barcelona")


##座標系の取得
pass_grp_Real <- ( dplyr::group_by( pass_Real, position_name , recipient_position) %>>% dplyr::filter(  completed==T, !is.na( position_name), !is.na( position_name)))
pass_grp_Real %>>% ( ~ cnt_smr_Real = dplyr::summarise( .,N=n())) %>>%   (grp~ cbind(cnt_smr_Real ,  dplyr::summarise_at(grp ,vars( contains(location) ), mean ) ) ) -> pass_smr_Real
cnt_smr_Real <- ( dplyr::summarize( pass_grp_Real, N= n()) %>>% as.data.frame)
pos_smr_Real <- (dplyr::summarize_at( pass_grp_Real, vars(contains("location")),mean)  %>>% as.data.frame)
pass_smr_Real <- dplyr::left_join( cnt_smr_Real , pos_smr_Real , by= c("position_name","recipient_position"))

##出し手としての平均位置/受け手としての平均位置と重みを取得
passer_pos <- ( pass_smr_Real %>>% dplyr::group_by(position_name) %>>% dplyr::summarise(  N = sum(N), X = mean(location1), Y = mean(location2) ) %>>%  as.data.frame)
receiver_pos <- ( pass_smr_Real %>>% dplyr::group_by(recipient_position) %>>% dplyr::summarise(  N = sum(N), X = mean(pass_end_location1), Y = mean(pass_end_location2) ) %>>%  as.data.frame)
colnames(passer_pos) <- paste0("Pass_",colnames(passer_pos) )
colnames(passer_pos)[1] <- "Position"
colnames(receiver_pos) <- paste0("Receive_",colnames(receiver_pos) )
colnames(receiver_pos)[1] <- "Position"
pos_Real <- dplyr::left_join( passer_pos , receiver_pos , by = "Position")
pos_Real <- ( pos_Real %>>% dplyr::mutate( X = (Pass_X * Pass_N + Receive_X* Receive_N) / (Pass_N + Receive_N), Y = (Pass_Y * Pass_N + Receive_Y* Receive_N) / (Pass_N + Receive_N) , Weight = Pass_N + Receive_N) )

以下のような形で、データが取得・整形できている。

f:id:ronri_rukeichi:20201217111544p:plain
綺麗にしたパスネットワーク情報

さて、あとはこれをggsoccerやggplot2を使ってプロットしていくだけだ。
線分を引くためには、geom_segment()を使う。

pass_smr_Real$from_X <- pos_Real[match( pass_smr_Real$position_name,pos_Real$Position ),"X"]
pass_smr_Real$from_Y <- pos_Real[match( pass_smr_Real$position_name,pos_Real$Position ),"Y"]
pass_smr_Real$to_X <- pos_Real[match( pass_smr_Real$recipient_position,pos_Real$Position ),"X"]
pass_smr_Real$to_Y <- pos_Real[match( pass_smr_Real$recipient_position,pos_Real$Position ),"Y"]

##描画する
gg_passNetR <- ggplot( data =pos_Real , mapping=aes( x = X , y=80- Y ,size=Weight * 3/50 )) +annotate_pitch(dimensions=pitch_statsbomb , fill="chartreuse4") + theme_pitch() + direction_label(x_label=60) + geom_point(color="firebrick3", shape=18) + ggtitle( "Pass Network(Real Madrid @2010/11 El Clasico)") +theme(plot.title = element_text(hjust = 0.5)) 

gg_passNetR2 <- gg_passNetR + geom_segment( data=pass_smr_Real, mapping=aes( x = from_X , y =80- from_Y , xend = to_X , yend = 80- to_Y , size = N *2/3* 0.3 , alpha = N /10 ) , color= "white")+ geom_text_repel(aes(label=Position), size=3.5 , color="yellow",fontface = "bold") +theme(legend.position = 'none')


これで出力されるのが下図である。

f:id:ronri_rukeichi:20201217114826p:plain
Rで出力したパスネットワーク図(レアル・マドリード@2010/11 クラシコ

いやーめっちゃかっこいいですね。テンションあがりますわ。

シュートの位置分布の可視化

シュートをどの位置から打ったのかを一覧化する。
イメージとしては、Football Labの試合結果ページ(例:名古屋vs湘南)にあるような形でシュートの位置分布を描画したい。

基本的にはFootball LABの図を踏襲するようにして、ゴールのときのみシュートの弾道を赤色にするようにしてみる。
以下のようなシンプルなコードで書ける。

#データ整形
shot_df <- evnt_df$Event$Shot
shot_Barca <-  dplyr::filter(shot_df , team_name == "Barcelona")
(shot_Barca %>>% dplyr::mutate( Shot_Result = if_else( shot_outcome_id ==97 , "Goal",if_else( shot_outcome_id %in% c(96,98,99) , "off-Target","Saved")))) -> shot_Barca

#描画
gg_shotB <- ggplot( data = shot_Barca,mapping=aes(x = location1 , y= 80-location2, fill= Shot_Result ,colour = Shot_Result ))  +annotate_pitch(dimensions=pitch_statsbomb , fill="olivedrab1") + theme_pitch() + direction_label(x_label=60) + ggtitle( "Shot Map(Barcelona@ 2010/11 El Clasico)")+theme(plot.title = element_text(hjust = 0.5), legend.position="none")  + coord_flip(xlim=c(58,121),ylim= c(-12,112))
gg_shotB2 <- gg_shotB +geom_segment( data = shot_Barca , mapping=aes(x= location1 , y =80- location2 , xend = shot_end_location1 , yend = 80- shot_end_location2, colour = Shot_Result),size= 0.7, linejoin="round",lineend="round")+ scale_colour_manual( limits= c("Goal","off-Target", "Saved"), values=c("red2", "grey55","grey55"), name="", guide=F)+ geom_point(color="midnightblue", shape=18,size=2.5)  

できあがった図は以下のようなもの。求める形で描画できている。

f:id:ronri_rukeichi:20201218094818p:plain
バルセロナ(2010-11のクラシコカンプノウ)のシュート位置分布

もうちょっと見栄えは工夫できそうだけどそれは今後の課題である。

Conclusion

とりあえずなぞってみただけど、面白かった。こういう楽しい体験のきっかけとなったsaenai氏の記事に感謝である。

これくらいの粒度のデータでマッシモ名古屋を分析して風間さんの時代との違いとかを分析できたら、超楽しいんだろう。
そういう時代が、来たらいいな。


ヒトリエ『アンチテーゼ・ジャンクガール』MV / HITORIE - Antithesis JunkGirl

Enjoy!!

*1:いやお前python勉強しろよ....って言われたらその通りなのだが。まぁPython→Rの翻訳するのも少しは勉強にはなっているだろう(希望的観測)