Gole w europejskich ligach

Skąd padają gole? Jak poruszają się piłkarze podczas meczu? Czy zawodnicy podają zawsze do tych samych?

Wszystkiego tego można się dowiedzieć jak tylko ma się odpowiednie dane… Warto przeglądać czasem Reddita - można znaleźć np. Soccer shots dataset (post już jest skasowany, ale kto pierwszy ten lepszy) zawierający link do arkusza z danymi zeskrapowanymi z ciekawej strony understat.com.

Skoro ktoś zdjął te dane i udało mi się zapisać do nich link to wykorzystajmy je!

W pierwszej kolejności musimy pobrać dane z Google Drive - lepiej mieć niż nie mieć ;)

# Będziemy potrzebowac standardowo pakietów:
library(tidyverse)
library(janitor)
library(igraph)

# a żeby pobrac dane z Google Drive wystarczy:
library(googledrive)

"1yeIl0rZldsmmRzdGUkG56QmX7UXI0PHQ" %>%
  as_id() %>%
  drive_get() %>%
  drive_download(path = "xg_master.csv")

I w pliczku xg_master.csv mamy interesujące nas dane. Zobaczmy co mamy w środku:

data <- read_csv("xg_master.csv") %>%
  clean_names() %>%
  mutate(player = stringi::stri_enc_tonative(player),
         assist_player = stringi::stri_enc_tonative(assist_player))

Po standardowych glimpse() wiemy co i jak, na przykład wiemy że dane pochodzą z kilku sezonów i obejmują ligi europejskie z następującymi drużynami:

data %>%
  distinct(league, team) %>%
  group_by(league) %>%
  arrange(team) %>%
  mutate(n = row_number()) %>%
  ungroup() %>%
  spread(league, team, fill = "") %>%
  select(-n) %>%
  kable() %>%
  kable_styling(bootstrap_options = c("striped", "hover", "condensed"))
BundesligaEPLLa_LigaLigue_1RFPLSerie_A
AugsburgArsenalAlavesAmiensAmkarAC Milan
Bayer LeverkusenAston VillaAlmeriaAngersAnzhi MakhachkalaAtalanta
Bayern MunichBournemouthAthletic ClubBordeauxArsenal TulaBenevento
Borussia DortmundBrightonAtletico MadridCaenCSKA MoscowBologna
Borussia M.GladbachBurnleyBarcelonaDijonDinamo MoscowCagliari
DarmstadtCardiffCelta VigoEvian Thonon GaillardFC KrasnodarCarpi
Eintracht FrankfurtChelseaCordobaGFC AjaccioFC OrenburgCesena
FC CologneCrystal PalaceDeportivo La CorunaGuingampFC RostovChievo
Fortuna DuesseldorfEvertonEibarLensFC UfaCrotone
FreiburgFulhamElcheLilleFC Yenisey KrasnoyarskEmpoli
Hamburger SVHuddersfieldEspanyolLorientFK AkhmatFiorentina
Hannover 96HullGetafeLyonKrylya Sovetov SamaraFrosinone
Hertha BerlinLeicesterGironaMarseilleKuban KrasnodarGenoa
HoffenheimLiverpoolGranadaMetzLokomotiv MoscowInter
IngolstadtManchester CityLas PalmasMonacoMordovyaJuventus
Mainz 05Manchester UnitedLeganesMontpellierRubin KazanLazio
NuernbergMiddlesbroughLevanteNancySKA-KhabarovskNapoli
PaderbornNewcastle UnitedMalagaNantesSpartak MoscowPalermo
RasenBallsport LeipzigNorwichOsasunaNiceTom TomskParma
Schalke 04Queens Park RangersRayo VallecanoNimesTorpedo MoscowParma Calcio 1913
VfB StuttgartSouthamptonReal BetisParis Saint GermainTosnoPescara
Werder BremenStokeReal MadridReimsUralRoma
WolfsburgSunderlandReal SociedadRennesZenit St. PetersburgSampdoria
SwanseaReal ValladolidSaint-EtienneSassuolo
TottenhamSD HuescaSC BastiaSPAL 2013
WatfordSevillaStrasbourgTorino
West Bromwich AlbionSporting GijonToulouseUdinese
West HamValenciaTroyesVerona
Wolverhampton WanderersVillarreal

Jedna kolumna określa nam skuteczność strzału, a druga - sytuację. możemy więc zobaczyć…

# skąd co pada?
data %>%
  ggplot() +
  geom_point(aes(x_coordinate, y_coordinate,
                 color = situation),
             alpha = 0.7, size = 0.5, show.legend = FALSE) +
  facet_grid(situation~result) +
  # boisko
  xlim(c(0, 1)) +
  ylim(c(0, 1)) +
  geom_rect(aes(xmin = 0, ymin = 0, xmax = 1, ymax = 1), fill = NA, color = "black") +
  geom_rect(aes(xmin = 0, ymin = 0, xmax = 0.5, ymax = 1), fill = NA, color = "black") +
  geom_rect(aes(xmin = 0, ymin = 0.2, xmax = 0.157, ymax = 0.8), fill = NA, color = "black") +
  geom_rect(aes(xmin = 1, ymin = 0.2, xmax = 1-0.157, ymax = 0.8), fill = NA, color = "black") +
  labs(title = "Skąd padają strzały?", x = "", y = "") +
  theme_void()

Prześledźmy na szybko rozkład informacji w kolumnach kategorycznych:

data %>% count(result, sort = TRUE) %>%
  kable() %>%
  kable_styling(bootstrap_options = c("striped", "hover", "condensed"))
resultn
MissedShots99454
BlockedShot61410
SavedShot58449
Goal25919
ShotOnPost4797
OwnGoal770
data %>% filter(result == "Goal") %>% count(shot_type, sort = TRUE) %>%
  kable() %>%
  kable_styling(bootstrap_options = c("striped", "hover", "condensed"))
shot_typen
RightFoot13841
LeftFoot7471
Head4449
OtherBodyPart158
data %>% filter(result == "Goal") %>% count(situation, sort = TRUE) %>%
  kable() %>%
  kable_styling(bootstrap_options = c("striped", "hover", "condensed"))
situationn
OpenPlay18237
FromCorner3057
Penalty2233
SetPiece1654
DirectFreekick738
data %>% filter(result == "Goal") %>% count(preceding_action, sort = TRUE) %>%
  kable() %>%
  kable_styling(bootstrap_options = c("striped", "hover", "condensed"))
preceding_actionn
Pass7119
Cross4594
Standard2971
Rebound2292
None2232
Throughball1447
TakeOn1412
Chipped1294
HeadPass804
Aerial767
BallRecovery350
BallTouch238
LayOff153
Dispossessed108
Tackle47
Interception23
BlockedPass21
Foul12
CornerAwarded8
GoodSkill6
Clearance3
End3
Goal3
OffsidePass3
SubstitutionOn3
Card2
Challenge2
FormationChange1
Start1

Ciekawe Tak sobie. Ciekawsze jest to, w której minucie padają gole. Zamiast liczyć jakąś średnią policzmy po prostu sumę bramek, które padły w kolejnych minutach:

data %>%
  filter(result == "Goal") %>%
  filter(minute <= 92) %>%
  count(minute) %>%
  ggplot() +
  geom_line(aes(minute, n), color = "green") +
  geom_smooth(aes(minute, n), color = "lightgreen", alpha = 0.5) +
  geom_vline(xintercept = c(45, 90)) +
  labs(title = "Liczba bramek w zależności od minuty meczu",
       x = "Minuta meczu",
       y = "Łączna liczba bramek")

Dlaczego tuż po 45 minucie mamy skok? To gol do szatnii - nieco przedłużona pierwsza połowa i gol w ostatniej chwili.

Niesamowite jest to, że im dłużej trwa mecz tym więcej bramek pada, tzn. więcej bramek pada w późniejszych minutach. Wydawałoby się, że zmęczenie i - czasem - granie na czas dadzą zna oo sobie i więcej będzie goli w pierwszej połowie. A tutaj zonk. W sumie fajny zonk, bo oznacza to tyle że emocje są do końca.

Podzielmy czas na 5-minutowe bloki i sprawdźmy jak w poszczególnych ligach wygląda liczba zdobytych bramek. Tym razem jednak licząc jaki procent bramek z całej ligi wpada w danym bloku:

# bloki po 5 minut, zależność od ligi
data %>%
  filter(result == "Goal") %>%
  mutate(minute = 5*( minute %/% 5) ) %>%
  count(minute, league) %>%
  group_by(league) %>%
  mutate(n = 100*n/sum(n)) %>%
  ungroup %>%
  ggplot() +
  geom_tile(aes(minute, league, fill = n), color = "gray50") +
  scale_fill_distiller(palette = "Reds", direction = 1) +
  geom_vline(xintercept = c(45, 90)) +
  labs(title = "Procent bramek w meczu zależności od minuty meczu",
       x = "Minuta meczu",
       y = "",
       fill = "Procent bramek w lidze")

Znowu widać 45 minutę. Szczególnie w angielskiej Premiere League można liczy na emocje. W sumie około 6% (co 16 mecz) to bramka pod koniec I połowy.

Skupmy się na chwilę na konkretnych zawodnikach. Na przykład Neymar (ulubiony piłkarz mojego syna) - czy strzela w Paris Saint Germain lepiej niż w Barcelonie?

data %>%
  filter(player == "Neymar") %>%
  filter(result == "Goal") %>%
  count(date, team) %>%
  ggplot() +
  geom_point(aes(date, n, color = team)) +
  labs(title = "Neymar: Barcelona vs PSG",
       color = "", x = "", y = "Liczba strzelonych\nbramek w meczu")

Strzela mniej więcej tak samo jeśli chodzi o liczbę zdobytych bramek w meczu. A czy strzela z innego miejsca? Może trener coś wypracował i zmieniło się ustawienie?

# skąd strzela
data %>%
  filter(player == "Neymar") %>%
  filter(result == "Goal") %>%
  ggplot() +
  geom_point(aes(x_coordinate, y_coordinate, color = team)) +
  # boisko
  xlim(c(0, 1)) +
  ylim(c(0, 1)) +
  geom_rect(aes(xmin = 0, ymin = 0, xmax = 1, ymax = 1), fill = NA, color = "black") +
  geom_rect(aes(xmin = 0, ymin = 0, xmax = 0.5, ymax = 1), fill = NA, color = "black") +
  geom_rect(aes(xmin = 0, ymin = 0.2, xmax = 0.157, ymax = 0.8), fill = NA, color = "black") +
  geom_rect(aes(xmin = 1, ymin = 0.2, xmax = 1-0.157, ymax = 0.8), fill = NA, color = "black") +
  labs(title = "Neymar: Barcelona vs PSG", x = "", y = "", color = "") +
  theme_void()

To, że czerwonych kropek jest więcej oznacza tylko tyle, że dłużej grał w Barcy. Strzela mniej więcej z tego samego miejsca. No, w PSG ładuje trochę sprzed pola karnego.

Zobaczmy jak inni. Na początek przygotujemy fragment danych - 12 piłkarzy, którzy strzelili najwięcej bramek:

top_strikers <- data %>%
  filter(result == "Goal") %>%
  count(player, sort = T) %>%
  top_n(12, n) %>%
  pull(player)
  • Lionel Messi
  • Cristiano Ronaldo
  • Luis Su�rez
  • Robert Lewandowski
  • Harry Kane
  • Edinson Cavani
  • Pierre-Emerick Aubameyang
  • Sergio Ag�ero
  • Alexandre Lacazette
  • Gonzalo Higua�n
  • Mauro Icardi
  • Antoine Griezmann

Skąd strzelają bramki ci panowie?

data %>%
  filter(player %in% top_strikers) %>%
  filter(result == "Goal") %>%
  ggplot() +
  geom_point(aes(x_coordinate, y_coordinate, color = player),
             show.legend = FALSE) +
  facet_wrap(~player) +
  # boisko
  xlim(c(0, 1)) +
  ylim(c(0, 1)) +
  geom_rect(aes(xmin = 0, ymin = 0, xmax = 1, ymax = 1), fill = NA, color = "black") +
  geom_rect(aes(xmin = 0, ymin = 0, xmax = 0.5, ymax = 1), fill = NA, color = "black") +
  geom_rect(aes(xmin = 0, ymin = 0.2, xmax = 0.157, ymax = 0.8), fill = NA, color = "black") +
  geom_rect(aes(xmin = 1, ymin = 0.2, xmax = 1-0.157, ymax = 0.8), fill = NA, color = "black") +
  labs(title = "Skąd padają bramki strzelone przez...", x = "", y = "") +
  theme_void()

Tutaj jest jedna ciekawostka - Messi strzelający gola z prawego dolnego rogu boiska. Możemy poszukać tej bramki:

data %>%
  filter(player == "Lionel Messi", result == "Goal") %>%
  filter(y_coordinate == min(y_coordinate)) %>%
  t() %>%
  as.data.frame() %>% rownames_to_column() %>% set_names(c("Feature", "Value")) %>%
  kable()  %>%
  kable_styling(bootstrap_options = c("striped", "hover", "condensed"))
FeatureValue
match_id3926
leagueLa_Liga
date2017-02-04 15:15:00
homeBarcelona
awayAthletic Club
home_goals3
away_goals0
teamBarcelona
minute39
playerLionel Messi
x_g0.05206308
shot_typeLeftFoot
resultGoal
x_coordinate0.952
y_coordinate0.116
situationDirectFreekick
assist_playerNA
preceding_actionStandard

Chwila w Google z wyszukaniem odpowiedniego meczu i mamy wynik jak poniżej:

A z jakiej odległości padają strzały? Nie będziemy tego przeliczać na metry, a na jednostki względne od środka bramki (punkt (1,0.5) w układzie współrzędnych boiska):

data %>%
  filter(player %in% top_strikers) %>%
  filter(result == "Goal") %>%
  mutate(distance = sqrt((x_coordinate-1)^2 + (y_coordinate-0.5)^2)) %>%
  group_by(player) %>%
  mutate(m_dist = mean(distance)) %>%
  ungroup() %>%
  arrange(m_dist) %>%
  mutate(player = fct_inorder(player)) %>%
  ggplot() +
  geom_boxplot(aes(player, distance, fill = player), color = "black",
               show.legend = FALSE) +
  coord_flip() +
  labs(title = "Z jakiej odległości od bramki padają gole?",
       x = "", y  = "Odległośc [skala względna]")

Wykres ułożono jest według średniej odległości, a kreska w pudełku mówi o medianie. Wcześniej widzieliśmy, że wszyscy strzelają głównie z pola karnego, zatem nic dziwnego że wyniki są zbliżone.

Poszukajmy najdalszego strzału:

data %>%
  filter(result == "Goal") %>%
  mutate(distance = sqrt((x_coordinate-1)^2 + (y_coordinate-0.5)^2)) %>%
  filter(distance == max(distance)) %>%
  t() %>%
  as.data.frame() %>% rownames_to_column() %>% set_names(c("Feature", "Value")) %>%
  kable() %>%
  kable_styling(bootstrap_options = c("striped", "hover", "condensed"))
FeatureValue
match_id5424
leagueBundesliga
date2014-09-20 14:30:00
homePaderborn
awayHannover 96
home_goals2
away_goals0
teamPaderborn
minute92
playerMoritz Stoppelkamp
x_g0.008978859
shot_typeRightFoot
resultGoal
x_coordinate0.225
y_coordinate0.475
situationOpenPlay
assist_playerUwe H�nemeier
preceding_actionNone
distance0.7754031

Znowu chwila z Google i mamy:

Jak wygląda rozkład wyników w poszczególnych ligach? Jaki wynik meczu jest najpopularniejszy? Poniższy wykres to obrazuje:

data %>%
  select(league, home_goals, away_goals, match_id) %>%
  distinct(match_id, .keep_all = TRUE) %>%
  count(league, away_goals, home_goals, sort = T) %>%
  group_by(league) %>%
  mutate(p = 100*n/sum(n)) %>%
  ungroup() %>%
  ggplot() +
  geom_tile(aes(home_goals, away_goals, fill = p), color = "black", show.legend = FALSE) +
  geom_text(aes(home_goals, away_goals, label = sprintf("%.f%%", p))) +
  scale_x_continuous(breaks = 0:10, limits = c(-1,11)) +
  scale_y_continuous(breaks = 0:10, limits = c(-1,11)) +
  scale_fill_distiller(palette = "Reds", direction = 1) +
  labs(title = "Rozkład wyników w meczach",
       x = "Gospodarze", y = "Goście") +
  facet_wrap(~league)

Najpopularniejsze wyniki to 1:1 albo 1:0 (czy też 0:1). Zdaje się, że rozkład liczby bramek w meczu to rozkład Poissona - to warto wiedzie, jeśli się chce budować jakieś modele przewidujące wyniki.

Przejdźmy na poziom dwóch drużyn. Na początek policzymy sobie dane mówiące o rozkładzie wyników spotkań wszystkich drużyn z każdej ligi pomiędzy sobą:

data_pairs <- data %>%
  select(home, away, league, home_goals, away_goals, match_id) %>%
  distinct(match_id, .keep_all = TRUE) %>%
  mutate(hG = if_else(home < away, home_goals, away_goals),
         aG = if_else(home < away, away_goals, home_goals),
         h = if_else(home < away, home, away),
         a = if_else(home < away, away, home)) %>%
  count(h, hG, a, aG) %>%
  group_by(h, a) %>%
  mutate(p = 100*n/sum(n)) %>%
  ungroup()

Teraz wybierzemy dwie drużyny i zobaczymy jakie wyniki padły w meczach między nimi:

A_team = "Manchester City"
B_team = "Liverpool"

data_sel <- data_pairs %>%
  filter(h %in% c(A_team, B_team),
         a %in% c(A_team, B_team))

home_team <- data_sel %>% pull(h) %>% unique()
away_team <- data_sel %>% pull(a) %>% unique()

data_sel %>%
  ggplot() +
  geom_tile(aes(hG, aG, fill = p), color = "black", show.legend = FALSE) +
  geom_text(aes(hG, aG, label = sprintf("%.f%%", p))) +
  scale_x_continuous(breaks = 0:10, limits = c(-1,11)) +
  scale_y_continuous(breaks = 0:10, limits = c(-1,11)) +
  scale_fill_distiller(palette = "Reds", direction = 1) +
  labs(title = paste("Rozkład wyników w meczach\n", home_team, "-", away_team),
       x = home_team, y = away_team)

Niestety nie ma bardziej zróżnicowanych danych - Real z Barcą wygląda podobnie (każdy z wyników padł tylko jeden raz, meczy też było 10). Szkoda.

Ale możemy inaczej - ile bramek strzela i ile traci Barcelona w lidze hiszpańskiej?

sel_team = "Barcelona"

# bilans rozgrywek
data_pairs %>%
  filter(h == sel_team | a == sel_team) %>%
  mutate(sel_goals = if_else(h == sel_team, hG, aG),
         other_goals =  if_else(h == sel_team, aG, hG),
         other =  if_else(h == sel_team, a, h)) %>%
  select(other, sel_goals, other_goals, n) %>%
  gather("key", "val", -other, -n) %>%
  mutate(key = factor(key, levels = c("sel_goals", "other_goals"),
                      labels = c("Bramki strzelone", "Bramki stracone"))) %>%
  ggplot() +
  geom_tile(aes(other, val, fill = n), color = "black") +
  scale_y_continuous(breaks = 0:10) +
  scale_fill_distiller(palette = "Reds", direction = 1) +
  facet_wrap(~key, ncol = 2) +
  theme(axis.text.x = element_text(angle = 90, vjust = 0, hjust = 1)) +
  labs(title = paste0("Liczba strzelonych/straconych bramek: ", sel_team),
       x = "Przeciwnik", y = "", fill = "Liczba bramek")

Oczywiście zamiast Barcelony możemy wybrać inny zespół.

A jak wygląda bilans bramek Barcy? Ile średnio goli strzela i ile traci w meczach ze swoimi rywalami z La Liga?

data_pairs %>%
  filter(h == sel_team | a == sel_team) %>%
  mutate(sel_goals = if_else(h == sel_team, hG, aG),
         other_goals =  if_else(h == sel_team, aG, hG),
         other =  if_else(h == sel_team, a, h)) %>%
  select(other, sel_goals, other_goals, n) %>%
  mutate(sel_goals = n * sel_goals,
         other_goals = n * other_goals) %>%
  group_by(other) %>%
  summarise(sel_goals = sum(sel_goals)/n(),
            other_goals = sum(other_goals)/n()) %>%
  ungroup() %>%
  mutate(delta = sel_goals - other_goals) %>%
  arrange(desc(delta)) %>%
  mutate(other = fct_inorder(other) %>% fct_rev()) %>%
  ggplot() +
  geom_col(aes(other, sel_goals), fill = "green") +
  geom_text(aes(other, sel_goals, label = sprintf("%.2f", sel_goals)), hjust = -0.1) +
  geom_col(aes(other, -other_goals), fill = "red") +
  geom_text(aes(other, -other_goals, label = sprintf("%.2f", other_goals)), hjust = 1.01) +
  geom_point(aes(other, delta), color = "black", size = 2) +
  # geom_text(aes(other, delta, label = sprintf("%.2f", delta)), hjust = -0.2) +
  coord_flip() +
  labs(title = paste0("Średnia liczba bramek strzelonych/straconych: ", sel_team),
       subtitle = "Zielony = bramki strzelone\nCzerwony = bramki stracone",
       x = "Przeciwnik", y = "")

Czarna kropka to różnica średnich - jak widać FC Barcelona z każdym rywalem jest na plusie.

Wróćmy do naszych top 12 zawodników. Kto im asystuje? Możemy narysować to na grafie:

passes_graph <- data %>%
  filter(result == "Goal") %>%
  filter(player %in% top_strikers) %>%
  filter(!is.na(assist_player)) %>%
  count(assist_player, player) %>%
  select(from = assist_player, to = player, weight = n) %>%
  graph_from_data_frame(directed = TRUE)


plot(passes_graph,
     vertex.color = if_else(V(passes_graph)$name %in% top_strikers, "blue", "lightblue"),
     vertex.size = if_else(V(passes_graph)$name %in% top_strikers, 7, 3),
     vertex.label.cex = 1,
     vertex.label.color =  if_else(V(passes_graph)$name %in% top_strikers, "gray80", "gray20"),
     edge.arrow.size = 0,
     edge.arrow.width = E(passes_graph)$weight,
     edge.width = E(passes_graph)$weight,
     edge.curved = TRUE)

Nie widać tego dobrze na powyższym obrazku, ale w odpowiednim powiększeniu (kliknij na obrazek) widać na przykład pana Lucasa Mourę - jest pomiędzy dwoma różnymi grupami. Dlaczego? Ano, bo przeszedł z PSG do Tottenham i podaje już komuś innemu :)

Przy użyciu pakietu networkD3 możemy przygotować z gotowego grafu (obiektu pakietu igraph) interaktywną wersję:

library(networkD3)

passes_graph_d3 <- igraph_to_networkD3(passes_graph)

forceNetwork(Links = passes_graph_d3$links, Nodes = passes_graph_d3$nodes,
             Source = 'source', Target = 'target',
             NodeID = 'name', Group = 'name',
             linkWidth = passes_graph_d3$links$value,
             fontSize = 11,
             opacityNoHover = 0.6,
             zoom = TRUE)

Prawda, że fajne?

To początek prac, jakieś pomysły po pół dnia przyglądania się danym. Jeśli masz ochotę - możesz użyć przygotowanego wyżej kodu do zbudowania na przykład aplikacji w Shiny, w której będzie można wybrać drużyny albo zawodników i dla każdego takiego wyboru wygenerować odpowiednie wykresiki. A gdyby jeszcze zasilać dane (trzeba by scrapper napisać) każdego dnia byłoby prawie online. Dlaczego understat.com tego nie ma?

Miło będzie jeśli docenisz trud autora stawiając dużą kawę. Wpadnij też na Dane i Analizy na Facebooku – tam więcej takich smaczków (szczególnie dla praktyków).