11 Kylian Mbappé
R pre-introductorio es generoso en code drills para limpiar y visualizar datos.
11.1 Tidy data
La regresión es una herramienta terriblemente importante a la hora de analizar datos. En el capítulo anterior demostramos que, contrario a lo que cabía esperar, correr regresiones en R no representa mayor dificultad.
Ahora sí vamos a entrar a un tema difícil de verdad: acomodar los datos para que se dejen analizar. Porque uno cree que Data Science consiste en producir modelos sofisticados y visualizaciones impresionantes hasta que se da cuenta de que en realidad la mayor parte del tiempo todo esto se trata de limpiar conjuntos de datos.
Hasta el momento hemos recurrido a data frames que son un encanto porque están dispuestos de forma tal que se pueden analizar de una vez, como iris
:
## Explore data
head(iris)
#> Sepal.Length Sepal.Width Petal.Length Petal.Width Species
#> 1 5.1 3.5 1.4 0.2 setosa
#> 2 4.9 3.0 1.4 0.2 setosa
#> 3 4.7 3.2 1.3 0.2 setosa
#> 4 4.6 3.1 1.5 0.2 setosa
#> 5 5.0 3.6 1.4 0.2 setosa
#> 6 5.4 3.9 1.7 0.4 setosa
Las cosas rara vez son así tan propicias. Lo normal es que haya que dedicar un montón de tiempo a curar y cuadrar los datos. Desarrollar severo skill set en materia de data wrangling es vital.
Es hora de enfrentarnos a un ejercicio más realista: ¿Qué podemos concluir sobre la absolutamente decadente e infame participación de la Selección Costarricense de Fútbol en el Mundial de Qatar 2022?
Lo averiguaremos gracias a un conjunto de datos que Jesús Lagos colgó en GitHub. El archivo datos_player_WC.rds
lo vamos a descargar directamente desde internet, con la función download.file()
, y lo vamos a almacenar en la carpeta input
:
## Set url
url <- "https://raw.githubusercontent.com/Jelagmil/Qatar2022data/main/datos_player_WC.rds"
## Download file
download.file(url, destfile = "input/datos_player_WC.rds")
## Load data
df.wc <- readRDS("input/datos_player_WC.rds")
Para empezar, tenemos que darle un vistazo a los datos para entender a qué nos estamos enfrentando:
## Explore data
head(df.wc)
#> # A tibble: 6 × 13
#> metricas valores player IdMatch IdTeam Locale Descr…¹
#> <chr> <dbl> <chr> <chr> <chr> <chr> <chr>
#> 1 DistanceWalk… 0 397870 400128… 43834 en-GB MUSAAB…
#> 2 DistanceLowS… 0 397870 400128… 43834 en-GB MUSAAB…
#> 3 DistanceJogg… 0 397870 400128… 43834 en-GB MUSAAB…
#> 4 DistanceHigh… 0 397870 400128… 43834 en-GB MUSAAB…
#> 5 DistanceHigh… 0 397870 400128… 43834 en-GB MUSAAB…
#> 6 AvgSpeed 0 397870 400128… 43834 en-GB MUSAAB…
#> # … with 6 more variables: NameTeam <chr>,
#> # Home.Abbreviation <chr>, Away.Abbreviation <chr>,
#> # Tipo <chr>, metricas2 <chr>, match <chr>, and
#> # abbreviated variable name ¹Description
#> # ℹ Use `colnames()` to see all variable names
Ok. Este data frame presenta un problema -uno serio y bastante común, de hecho-: tiene variables regadas en las filas.
¿Ah? Vean estas dos columnas:
## Get dimensions
dim.df.wc <- dim(df.wc)
## Explore data
df.wc |>
select(1, 2)
#> # A tibble: 154,559 × 2
#> metricas valores
#> <chr> <dbl>
#> 1 DistanceWalking 0
#> 2 DistanceLowSpeedSprinting 0
#> 3 DistanceJogging 0
#> 4 DistanceHighSpeedSprinting 0
#> 5 DistanceHighSpeedRunning 0
#> 6 AvgSpeed 0
#> 7 TotalDistance 0
#> 8 TopSpeed 0
#> 9 Sprints 0
#> 10 SpeedRuns 0
#> # … with 154,549 more rows
#> # ℹ Use `print(n = ...)` to see more rows
Evidentemente, de la columna metricas
cuelgan un montón de variables en sí mismas, variables cuyos valores están en la columna de al lado: valores
.
df.wc
es un data frame con estadísticas para cada jugador en cada partido disputado. Voy a ejecutar una filtrado con selección de variables para que lo aprecien mejor:
## Explore data
df.wc |>
filter(Description == "Joel CAMPBELL" | Description == "Keylor NAVAS",
metricas == "TimePlayed" | metricas == "Passes") |>
select(metricas, valores, Description, match)
#> # A tibble: 12 × 4
#> metricas valores Description match
#> <chr> <dbl> <chr> <chr>
#> 1 TimePlayed 100 Keylor NAVAS ESP - CRC
#> 2 Passes 26 Keylor NAVAS ESP - CRC
#> 3 TimePlayed 100 Joel CAMPBELL ESP - CRC
#> 4 Passes 24 Joel CAMPBELL ESP - CRC
#> 5 TimePlayed 92 Keylor NAVAS JPN - CRC
#> 6 Passes 42 Keylor NAVAS JPN - CRC
#> 7 TimePlayed 95 Joel CAMPBELL JPN - CRC
#> 8 Passes 46 Joel CAMPBELL JPN - CRC
#> 9 Passes 34 Keylor NAVAS CRC - GER
#> 10 TimePlayed 92 Keylor NAVAS CRC - GER
#> 11 Passes 63 Joel CAMPBELL CRC - GER
#> 12 TimePlayed 92 Joel CAMPBELL CRC - GER
Debemos reacomodar este data frame para que las variables (como TimePlayed
o Passes
) sean columnas, con lo cual concentraremos todas las estadísticas de Joel CAMPBELL
o Keylor NAVAS
en una única fila por cada partido; además, vamos a quitar algunas que no necesitaremos y a cambiar el nombre Description
por uno más exacto:
## Reshape data
df.wc <- df.wc |>
select(!c(IdMatch, IdTeam,player, Tipo, metricas2, Home.Abbreviation, Away.Abbreviation, Locale)) |> # Get rid of irrelevant variables
pivot_wider( # Transpose rows to columns
names_from = metricas,
values_from = c(valores)
) |>
rename( # Choose appropriate names
player = Description
)
Ahora exploremos cómo se ve el data frame a la luz de los casos de Joel CAMPBELL
o Keylor NAVAS
:
## Explore data
df.wc |>
filter(player == "Joel CAMPBELL" | player == "Keylor NAVAS")
#> # A tibble: 6 × 75
#> player NameT…¹ match Dista…² Dista…³ Dista…⁴ Dista…⁵
#> <chr> <chr> <chr> <dbl> <dbl> <dbl> <dbl>
#> 1 Keylor NAVAS Costa … ESP … 3193. 21.4 534. 0
#> 2 Joel CAMPBE… Costa … ESP … 3991. 335. 4130. 9.95
#> 3 Keylor NAVAS Costa … JPN … 2943. 18.2 438. 0
#> 4 Joel CAMPBE… Costa … JPN … 3837. 259. 3500. 88.3
#> 5 Keylor NAVAS Costa … CRC … 3051. 22.6 508. 0
#> 6 Joel CAMPBE… Costa … CRC … 4224. 513. 3205. 155.
#> # … with 68 more variables: DistanceHighSpeedRunning <dbl>,
#> # AvgSpeed <dbl>, TotalDistance <dbl>, TopSpeed <dbl>,
#> # Sprints <dbl>, SpeedRuns <dbl>,
#> # DistributionsCompletedUnderPressure <dbl>,
#> # LinebreaksAttemptedAttackingLineCompleted <dbl>,
#> # ReceptionsBetweenMidfieldAndDefensiveLine <dbl>,
#> # OffersToReceiveInFront <dbl>, …
#> # ℹ Use `colnames()` to see all variable names
Se va viendo mejor… Ahora las variables que estaban desparramadas en filas sí son columnas.
Naturalmente, hubo un cambio importante en el número de filas y columnas. La función dim()
nos indica el número de filas y el de columnas, en ese orden. Originalmente, df.wc
tenía estas dimensiones:
## Print
dim.df.wc
#> [1] 154559 13
Ahora tiene estas:
## Explore data
dim(df.wc) # observations, columns
#> [1] 3162 75
Atención con la variable NameTeam
. Voy a aplicar la función unique()
para extraer cómo están escritos los nombres de los equipos.
## Explore variable
unique(df.wc$NameTeam)
#> [1] "Catar" "Ecuador"
#> [3] "Irán" "Inglaterra"
#> [5] "Senegal" "Países Bajos"
#> [7] "Estados Unidos" "Gales"
#> [9] "Arabia Saudí" "Argentina"
#> [11] "Túnez" "Dinamarca"
#> [13] "México" "Polonia"
#> [15] "Francia" "Australia"
#> [17] "Marruecos" "Croacia"
#> [19] "Japón" "Alemania"
#> [21] "Costa Rica" "España"
#> [23] "Canadá" "Bélgica"
#> [25] "Camerún" "Suiza"
#> [27] "República de Corea" "Uruguay"
#> [29] "Ghana" "Portugal"
#> [31] "Brasil" "Serbia"
Ahora que sé los nombres, agregaré una variable que me indique cuántos partidos jugó en total cada uno de esos equipos (porque estamos hablando de un torneo de eliminación, unos equipos juegan más partidos que otros).
Miren qué útiles son los for loops para encargarle a R que pase fila por fila (esta instrucción viene de for(i in 1:nrow(df.wc))
) y, según lo que se encuentre en la variable NameTeam
, indique en la nueva variable matches
si el equipo jugó 7
, 5
, 4
o 3
partidos:
## Create variable
df.wc$matches <- 999
## Inspect
table(df.wc$matches)
#>
#> 999
#> 3162
## Fill variable
for(i in 1:nrow(df.wc)){
if(df.wc$NameTeam[i] %in% c("Argentina", "Francia",
"Croacia", "Marruecos")){
df.wc$matches[i] <- 7
} else if(df.wc$NameTeam[i] %in% c("Países Bajos", "Brasil",
"Inglaterra", "Portugal")){
df.wc$matches[i] <- 5
} else if(df.wc$NameTeam[i] %in% c("Estados Unidos", "Australia",
"Japón", "República de Corea",
"Senegal", "Polonia",
"España", "Suiza")){
df.wc$matches[i] <- 4
} else{
df.wc$matches[i] <- 3
}
}
## Inspect
table(df.wc$matches)
#>
#> 3 4 5 7
#> 1190 804 484 684
## Explore data
dim(df.wc)
#> [1] 3162 76
La mitad de los equipos quedan eliminados en fase de grupos, por lo que juegan sólo 3
partidos. Analicen la estructura del for loop anterior y cómo la capitalicé para ahorrarme el tener que escribir los nombres de la mitad de los equipos que jugaron apenas 3
partidos. En pocas palabras, un for loop toma un bloque de código y lo repite varias veces:
## Fill variable
for(i in 1:nrow(df.wc)){ # for loop!
if(IF THIS IS TRUE){
!
THEN DO THISFALSE, TRY THE FOLLOWING else if
IF else if(IF THIS IS TRUE){
}
THEN DO THISFALSE, TRY THE FOLLOWING else if
IF else if(IF THIS IS TRUE){
}
THEN DO THISFALSE, TRY THE FOLLOWING else
IF else{
} !
OK, JUST DO THIS
}# for loop ends here! }
Exploremos la variable la variable TimePlayed
rápidamente:
## Explore data
summary(df.wc$TimePlayed)
#> Min. 1st Qu. Median Mean 3rd Qu. Max. NA's
#> -4.00 40.25 83.00 70.32 96.00 138.00 1164
Tiene dos problemas esa variable: ¿Por qué hay tantos NA
? ¿Y por qué hay números negativos? Debemos salir de ambas dudas, así que revisaremos algunos casos conocidos:
## Filter
df.wc |>
filter(NameTeam == "Costa Rica") |>
select(player, match, TimePlayed) |>
arrange(TimePlayed)
#> # A tibble: 77 × 3
#> player match TimePlayed
#> <chr> <chr> <dbl>
#> 1 Daniel CHACON JPN - CRC -3
#> 2 Anthony CONTRERAS CRC - GER -1
#> 3 Roan WILSON CRC - GER -1
#> 4 Youstin SALAS JPN - CRC 2
#> 5 Ronald MATARRITA ESP - CRC 14
#> 6 Jewison BENNETTE CRC - GER 17
#> 7 Ronald MATARRITA CRC - GER 18
#> 8 Brandon AGUILERA ESP - CRC 24
#> 9 Brandon AGUILERA JPN - CRC 27
#> 10 Jewison BENNETTE JPN - CRC 27
#> # … with 67 more rows
#> # ℹ Use `print(n = ...)` to see more rows
## Filter
df.wc |>
filter(NameTeam == "Costa Rica") |>
select(player, match, TimePlayed) |>
arrange(desc(TimePlayed)) |>
tail()
#> # A tibble: 6 × 3
#> player match TimePlayed
#> <chr> <chr> <dbl>
#> 1 Bryan RUIZ CRC - GER NA
#> 2 Daniel CHACON CRC - GER NA
#> 3 Alvaro ZAMORA CRC - GER NA
#> 4 Anthony HERNANDEZ CRC - GER NA
#> 5 Carlos MARTINEZ CRC - GER NA
#> 6 Douglas LOPEZ CRC - GER NA
Y como extrema precaución, voy a fijarme en todos los casos en los que TimePlayed
es un número negativo:
## Filter
df.wc |>
filter(TimePlayed < 0) |>
arrange(TimePlayed) |>
select(player, match, TimePlayed)
#> # A tibble: 13 × 3
#> player match TimePlayed
#> <chr> <chr> <dbl>
#> 1 MOHAMMED ALBURAYK ARG - KSA -4
#> 2 Daniel CHACON JPN - CRC -3
#> 3 Jack GREALISH ENG - FRA -3
#> 4 Kristijan JAKIC CRO - MAR -3
#> 5 Noah OKAFOR SRB - SUI -2
#> 6 Matthijs DE LIGT NED - USA -2
#> 7 Wout WEGHORST NED - USA -2
#> 8 Marten DE ROON SEN - NED -1
#> 9 Joseff MORRELL USA - WAL -1
#> 10 Anthony CONTRERAS CRC - GER -1
#> 11 Roan WILSON CRC - GER -1
#> 12 Matthias GINTER CRC - GER -1
#> 13 CHO Yumin KOR - POR -1
Una búsqueda rápida en Google me persuade de que los números negativos corresponden a jugadores que ingresaron de cambio durante el tiempo de reposición. Tendré que hacer algo al respecto, sin duda. También daré de baja los NA
en la variable TimePlayed
para deshacerme de los jugadores que no tuvieron participación alguna.
Cabe recordar que df.wc
tiene una fila para cada jugador en cada partido diferente; las filas que están vacías (NA
) son las de aquellos casos en los que el jugador no disputó minutos. Un ejemplo: dado que Costa Rica jugó tres partidos, Daniel Chacón tiene tres filas, pero recordemos que el muchacho apenas jugó contra Japón, de ahí los dos NA
, y en el partido contra Japón ingresó de cambio en el tiempo de reposición, de ahí que se lo registraran en negativo:
## Filter
df.wc |>
filter (player == "Daniel CHACON") |>
select(match, TimePlayed)
#> # A tibble: 3 × 2
#> match TimePlayed
#> <chr> <dbl>
#> 1 ESP - CRC NA
#> 2 JPN - CRC -3
#> 3 CRC - GER NA
En fin, voy a remover los NA
y a expresar la variable TimePlayed
en valores absolutos para deshacer el problema de los valores negativos:
## Count
nrow(df.wc)
#> [1] 3162
## Drop NAs
df.wc <- df.wc[which(!is.na(df.wc$TimePlayed)), ]
## Transform
df.wc <- df.wc |>
mutate(TimePlayed = abs(TimePlayed))
## Count
nrow(df.wc)
#> [1] 1998
No importa haber perdido tantas filas. Son muchos NA
porque son muchos los jugadores que se quedan en banca cada partido.
Ahora lo que me intriga son los jugadores que reportan cero minutos:
## Summary
summary(df.wc$TimePlayed)
#> Min. 1st Qu. Median Mean 3rd Qu. Max.
#> 0.00 40.25 83.00 70.35 96.00 138.00
min(df.wc$TimePlayed)
#> [1] 0
Mi hipótesis es que son jugadores que al instante de ingresar de cambio el encuentro concluyó. Vale más que lo revisemos:
## Filter
df.wc |>
filter(TimePlayed == 0) |>
select(match, player, TimePlayed)
#> # A tibble: 7 × 3
#> match player TimePlayed
#> <chr> <chr> <dbl>
#> 1 POR - GHA Antoine SEMENYO 0
#> 2 POR - GHA Daniel Kofi KYEREH 0
#> 3 CMR - SRB Luka JOVIC 0
#> 4 IRN - USA Mohammad KANAANI 0
#> 5 CRC - GER Lukas KLOSTERMANN 0
#> 6 SRB - SUI Predrag RAJKOVIC 0
#> 7 NED - USA Jordan MORRIS 0
Esos casos me inquietan mucho. Creo que pueden existir errores de origen, porque recuerdo perfectamente bien que el alemán Lukas KLOSTERMANN
ingresó al segundo tiempo contra Costa Rica. Otra búsqueda en Google me permite concluir que, a excepción de Klostermann, el resto de casos son, una de dos, jugadores que en efecto ingresaron y el partido acabo al instante, o bien, jugadores que no jugaron ni un segundo pero les sacaron tarjeta amarilla en la banca.
Sólo voy a corregir el caso del alemán. Le voy a imputar un 45, que no es exacto pero sí mucho mejor que 0:
## Input
df.wc[df.wc$match == "CRC - GER" & df.wc$player == "Lukas KLOSTERMANN", "TimePlayed"] <- 45
## Verify
subset(df.wc, match == "CRC - GER" & player == "Lukas KLOSTERMANN", "TimePlayed")
#> # A tibble: 1 × 1
#> TimePlayed
#> <dbl>
#> 1 45
Vamos a quedarnos sólo con las variable que utilizaremos más adelante:
## Select
df.wc <- df.wc |>
select(player, NameTeam, match,
Passes, PassesCompleted,
FoulsFor, FoulsAgainst,
TopSpeed, TotalDistance,
Goals, TimePlayed,
DefensivePressuresApplied, OffersToReceiveTotal,
ReceptionsUnderNoPressure, ReceptionsUnderPressure)
## Explore data
dim(df.wc)
#> [1] 1998 15
Ahora sí, exploremos los datos en serio. Nos vamos a apoyar en una de mis funciones favoritas: skimr::skim()
## Explore data
skim(df.wc) |> kable()
skim_type | skim_variable | n_missing | complete_rate | character.min | character.max | character.empty | character.n_unique | character.whitespace | numeric.mean | numeric.sd | numeric.p0 | numeric.p25 | numeric.p50 | numeric.p75 | numeric.p100 | numeric.hist |
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
character | player | 0 | 1.0000000 | 4 | 26 | 0 | 680 | 0 | NA | NA | NA | NA | NA | NA | NA | NA |
character | NameTeam | 0 | 1.0000000 | 4 | 18 | 0 | 32 | 0 | NA | NA | NA | NA | NA | NA | NA | NA |
character | match | 0 | 1.0000000 | 9 | 9 | 0 | 64 | 0 | NA | NA | NA | NA | NA | NA | NA | NA |
numeric | Passes | 0 | 1.0000000 | NA | NA | NA | NA | NA | 32.0840841 | 25.9810789 | 0 | 12.00 | 27.00 | 46.00 | 212.00 | ▇▂▁▁▁ |
numeric | PassesCompleted | 0 | 1.0000000 | NA | NA | NA | NA | NA | 27.4479479 | 24.3063004 | 0 | 9.00 | 21.00 | 39.00 | 205.00 | ▇▂▁▁▁ |
numeric | FoulsFor | 0 | 1.0000000 | NA | NA | NA | NA | NA | 0.7602603 | 1.0953913 | 0 | 0.00 | 0.00 | 1.00 | 9.00 | ▇▂▁▁▁ |
numeric | FoulsAgainst | 0 | 1.0000000 | NA | NA | NA | NA | NA | 0.8008008 | 1.0658628 | 0 | 0.00 | 0.00 | 1.00 | 6.00 | ▇▂▁▁▁ |
numeric | TopSpeed | 93 | 0.9534535 | NA | NA | NA | NA | NA | 30.2632966 | 3.0959320 | 0 | 28.97 | 30.74 | 32.24 | 35.66 | ▁▁▁▂▇ |
numeric | TotalDistance | 93 | 0.9534535 | NA | NA | NA | NA | NA | 7372.9920210 | 3445.4172222 | 0 | 4303.45 | 8255.69 | 10151.82 | 16639.61 | ▅▆▇▆▁ |
numeric | Goals | 0 | 1.0000000 | NA | NA | NA | NA | NA | 0.0850851 | 0.3192501 | 0 | 0.00 | 0.00 | 0.00 | 3.00 | ▇▁▁▁▁ |
numeric | TimePlayed | 0 | 1.0000000 | NA | NA | NA | NA | NA | 70.3713714 | 33.6714251 | 0 | 41.00 | 83.00 | 96.00 | 138.00 | ▃▂▃▇▁ |
numeric | DefensivePressuresApplied | 0 | 1.0000000 | NA | NA | NA | NA | NA | 18.6751752 | 15.8619828 | 0 | 6.00 | 15.00 | 27.00 | 98.00 | ▇▃▁▁▁ |
numeric | OffersToReceiveTotal | 0 | 1.0000000 | NA | NA | NA | NA | NA | 36.5995996 | 26.4962056 | 0 | 16.00 | 31.00 | 52.00 | 149.00 | ▇▆▂▁▁ |
numeric | ReceptionsUnderNoPressure | 0 | 1.0000000 | NA | NA | NA | NA | NA | 21.1271271 | 22.1678139 | 0 | 5.00 | 14.00 | 30.00 | 195.00 | ▇▂▁▁▁ |
numeric | ReceptionsUnderPressure | 0 | 1.0000000 | NA | NA | NA | NA | NA | 11.9849850 | 9.7045888 | 0 | 5.00 | 10.00 | 17.00 | 64.00 | ▇▃▁▁▁ |
Esta primera exploración de los datos revela detalles de máxima relevancia. Por ejemplo, a juzgar por las variables que presentan NA
(TopSpeed
y TotalDistance
), algún problema debió haber con los sensores de movimiento en algunos partidos:
## Explore NA
df.wc[which(is.na(df.wc$TotalDistance)), ]
#> # A tibble: 93 × 15
#> player NameT…¹ match Passes Passe…² Fouls…³ Fouls…⁴
#> <chr> <chr> <chr> <dbl> <dbl> <dbl> <dbl>
#> 1 Mehdi TAREMI Irán WAL … 25 17 3 0
#> 2 Harry WILSON Gales WAL … 23 18 1 0
#> 3 Connor ROBE… Gales WAL … 35 27 0 0
#> 4 Brennan JOH… Gales WAL … 7 5 0 0
#> 5 Aaron RAMSEY Gales WAL … 51 39 1 0
#> 6 Joe RODON Gales WAL … 52 50 0 1
#> 7 Gareth BALE Gales WAL … 22 16 1 0
#> 8 Alphonso DA… Canadá CRO … 39 31 2 1
#> 9 Cyle LARIN Canadá CRO … 8 5 0 0
#> 10 Tajon BUCHA… Canadá CRO … 33 22 2 1
#> # … with 83 more rows, 8 more variables: TopSpeed <dbl>,
#> # TotalDistance <dbl>, Goals <dbl>, TimePlayed <dbl>,
#> # DefensivePressuresApplied <dbl>,
#> # OffersToReceiveTotal <dbl>,
#> # ReceptionsUnderNoPressure <dbl>,
#> # ReceptionsUnderPressure <dbl>, and abbreviated variable
#> # names ¹NameTeam, ²PassesCompleted, ³FoulsFor, …
#> # ℹ Use `print(n = ...)` to see more rows, and `colnames()` to see all variable names
No voy a borrar esos casos (porque sí están completos para las demás variables) ni a borrar las variables tampoco (porque si bien no están completas la verdad es que no les falta casi nada). Simplemente voy a analizar esas variables con precaución cuando llegue el momento.
Alcanzado este punto, hemos aprendido cómo la limpieza de los datos está llena de decisiones fundamentales respecto a qué filas y columnas dejar o no, cuál debe ser la forma adecuada del data set, qué transformaciones cabe operar sobre las variables (columnas), etcétera.
En el conjunto de datos procesado del que disponemos ahora, cada fila (observación) corresponde a lo que hizo un jugador en un partido, o sea, tenemos en filas separadas las estadísticas de Joel Campbell contra España, de Joel contra Japón, de Joel contra Alemania, de Keysher Fuller contra España, de Fuller contra Japón, y así sucesivamente.
Voy a probar lo anterior extrayendo sólo los datos que corresponden a Joel Campbell:
## Filter
df.wc |>
filter(player == "Joel CAMPBELL")
#> # A tibble: 3 × 15
#> player NameT…¹ match Passes Passe…² Fouls…³ Fouls…⁴
#> <chr> <chr> <chr> <dbl> <dbl> <dbl> <dbl>
#> 1 Joel CAMPBELL Costa … ESP … 24 19 3 2
#> 2 Joel CAMPBELL Costa … JPN … 46 38 5 0
#> 3 Joel CAMPBELL Costa … CRC … 63 46 3 1
#> # … with 8 more variables: TopSpeed <dbl>,
#> # TotalDistance <dbl>, Goals <dbl>, TimePlayed <dbl>,
#> # DefensivePressuresApplied <dbl>,
#> # OffersToReceiveTotal <dbl>,
#> # ReceptionsUnderNoPressure <dbl>,
#> # ReceptionsUnderPressure <dbl>, and abbreviated variable
#> # names ¹NameTeam, ²PassesCompleted, ³FoulsFor, …
#> # ℹ Use `colnames()` to see all variable names
Y digamos que quiero torturarme viendo las estadísticas de Anthony Contreras:
## Filter
df.wc |>
filter(player == "Anthony CONTRERAS")
#> # A tibble: 3 × 15
#> player NameT…¹ match Passes Passe…² Fouls…³ Fouls…⁴
#> <chr> <chr> <chr> <dbl> <dbl> <dbl> <dbl>
#> 1 Anthony CONT… Costa … ESP … 4 3 0 0
#> 2 Anthony CONT… Costa … JPN … 7 5 1 1
#> 3 Anthony CONT… Costa … CRC … 6 5 0 0
#> # … with 8 more variables: TopSpeed <dbl>,
#> # TotalDistance <dbl>, Goals <dbl>, TimePlayed <dbl>,
#> # DefensivePressuresApplied <dbl>,
#> # OffersToReceiveTotal <dbl>,
#> # ReceptionsUnderNoPressure <dbl>,
#> # ReceptionsUnderPressure <dbl>, and abbreviated variable
#> # names ¹NameTeam, ²PassesCompleted, ³FoulsFor, …
#> # ℹ Use `colnames()` to see all variable names
Ahora quiero ver las de Kylian Mbappé, quien tiene más entradas que Campbell y Contreras porque jugó más partidos:
## Filter
df.wc |>
filter(player == "Kylian MBAPPE")
#> # A tibble: 7 × 15
#> player NameT…¹ match Passes Passe…² Fouls…³ Fouls…⁴
#> <chr> <chr> <chr> <dbl> <dbl> <dbl> <dbl>
#> 1 Kylian MBAPPE Francia FRA … 44 37 0 1
#> 2 Kylian MBAPPE Francia FRA … 31 26 3 0
#> 3 Kylian MBAPPE Francia TUN … 23 18 1 0
#> 4 Kylian MBAPPE Francia FRA … 37 33 1 1
#> 5 Kylian MBAPPE Francia ENG … 26 22 2 0
#> 6 Kylian MBAPPE Francia FRA … 14 10 0 3
#> 7 Kylian MBAPPE Francia ARG … 19 15 0 2
#> # … with 8 more variables: TopSpeed <dbl>,
#> # TotalDistance <dbl>, Goals <dbl>, TimePlayed <dbl>,
#> # DefensivePressuresApplied <dbl>,
#> # OffersToReceiveTotal <dbl>,
#> # ReceptionsUnderNoPressure <dbl>,
#> # ReceptionsUnderPressure <dbl>, and abbreviated variable
#> # names ¹NameTeam, ²PassesCompleted, ³FoulsFor, …
#> # ℹ Use `colnames()` to see all variable names
Había filtrado los datos de Joel pero no los guardé. Voy a guardarlos para que podamos manipular ese nuevo data frame con mayor comodidad:
## Filter
df.joel <- df.wc |>
filter(player == "Joel CAMPBELL")
¿Cuántos pases intentó Joel en cada partido? Seleccionemos esas dos columnas:
## Select
df.joel |>
select(match, Passes)
#> # A tibble: 3 × 2
#> match Passes
#> <chr> <dbl>
#> 1 ESP - CRC 24
#> 2 JPN - CRC 46
#> 3 CRC - GER 63
Joel hizo más pases en cada siguiente partido, un patrón más o menos consistente con el Mundial atroz que jugó la Sele.
Una necesidad básica es la de describir nuestros datos del modo más preciso posible. La media (la estudiamos en otro capítulo) es una de las mejores alternativas para ello:
## Compute
mean(df.joel$Passes)
#> [1] 44.33333
Una forma precisa de describir cuántos pases hizo Joel Campbell en el Mundial sería decir que, en promedio, hizo 44.3333333 pases por partido.
Si esos hizo Joel en promedio, ¿cuántos pases habrá hecho Lionel Messi? Usamos la función summarize()
para colapsar datos en una única cifra:
## Summarize
df.wc |>
group_by(player) |>
filter(player == "Joel CAMPBELL") |>
summarize(PassesMEAN = mean(Passes))
#> # A tibble: 1 × 2
#> player PassesMEAN
#> <chr> <dbl>
#> 1 Joel CAMPBELL 44.3
## Summarize
df.wc |>
group_by(player) |>
filter(player == "Lionel MESSI") |>
summarize(PassesMEAN = mean(Passes))
#> # A tibble: 1 × 2
#> player PassesMEAN
#> <chr> <dbl>
#> 1 Lionel MESSI 50.9
¿Y cuántos habrá hecho Rodrigo De Paul?
## Summarize
df.wc |>
group_by(player) |>
filter(player == "Rodrigo DE PAUL") |>
summarize(PassesMEAN = mean(Passes))
#> # A tibble: 1 × 2
#> player PassesMEAN
#> <chr> <dbl>
#> 1 Rodrigo DE PAUL 77.6
Tremendo De Paul… ¿Cuántos pases en promedio hizo Kylian Mbappé?
## Summarize
df.wc |>
group_by(player) |>
filter(player == "Kylian MBAPPE") |>
summarize(PassesMEAN = mean(Passes))
#> # A tibble: 1 × 2
#> player PassesMEAN
#> <chr> <dbl>
#> 1 Kylian MBAPPE 27.7
El número promedio de pases por partido de Mbappé me parece bajo, muy bajo. Sin duda, quiero verlo con mis propios ojos:
## Filter
df.wc |>
filter(player == "Kylian MBAPPE") |>
select(match, Passes) |>
arrange(desc(Passes))
#> # A tibble: 7 × 2
#> match Passes
#> <chr> <dbl>
#> 1 FRA - AUS 44
#> 2 FRA - POL 37
#> 3 FRA - DEN 31
#> 4 ENG - FRA 26
#> 5 TUN - FRA 23
#> 6 ARG - FRA 19
#> 7 FRA - MAR 14
Comparemos a Mbappé con un jugador similar de su mismo equipo:
## Filter
df.wc |>
filter(player == "Antoine GRIEZMANN") |>
select(match, Passes) |>
arrange(desc(Passes))
#> # A tibble: 7 × 2
#> match Passes
#> <chr> <dbl>
#> 1 FRA - AUS 71
#> 2 FRA - POL 45
#> 3 FRA - DEN 40
#> 4 ENG - FRA 39
#> 5 TUN - FRA 30
#> 6 ARG - FRA 28
#> 7 FRA - MAR 25
Digamos que planeo seguir analizando la variable Passes
. Tardaría una eternidad haciendo lo que he hecho con Joel, Messi, De Paul, Mbappé y todos los demás uno por uno.
Para calcular el promedio de pases por partido de cada jugador, haré que el conjunto de datos esté agrupado por jugador y equipo (no debería hacer mucha diferencia incluir el equipo; podría agrupar sólo por jugador y atenerme a que seguro hay un Neymar y nada más, el que juega en Brasil, pero incluiré el equipo en caso de que haya dos jugadores que se llamen igual en diferentes equipos).
Se agrupa con la función group_by()
, tal como se demuestra en seguida:
## Summarize
df.wc |>
group_by(player, NameTeam) |>
summarize(PassesMEAN = mean(Passes)) |>
head(10)
#> `summarise()` has grouped output by 'player'. You can
#> override using the `.groups` argument.
#> # A tibble: 10 × 3
#> # Groups: player [10]
#> player NameTeam PassesMEAN
#> <chr> <chr> <dbl>
#> 1 Aaron MOOY Australia 50
#> 2 Aaron RAMSEY Gales 38
#> 3 Abde EZZALZOULI Marruecos 4.33
#> 4 Abdelhamid SABIRI Marruecos 10.8
#> 5 ABDELKARIM HASSAN Catar 49.7
#> 6 Abderrazak HAMDALLAH Marruecos 2.5
#> 7 Abdou DIALLO Senegal 44.2
#> 8 Abdul Fatawu ISSAHAKU Ghana 1
#> 9 ABDULAZIZ HATEM Catar 29.7
#> 10 Abdulelah ALAMRI Arabia Saudí 31.3
R obtuvo los promedios de pases por partido pero me los ordenó alfabéticamente. Usaré la función arrange()
porque yo los quiero ordenados de mayor a menor:
## Summarize
df.wc |>
group_by(player, NameTeam) |>
summarize(PassesMEAN = mean(Passes)) |>
arrange(PassesMEAN) |>
head(10)
#> `summarise()` has grouped output by 'player'. You can
#> override using the `.groups` argument.
#> # A tibble: 10 × 3
#> # Groups: player [10]
#> player NameTeam PassesMEAN
#> <chr> <chr> <dbl>
#> 1 CHO Yumin República de Corea 0
#> 2 Daniel CHACON Costa Rica 0
#> 3 Jerome NGOM MBEKELI Camerún 0
#> 4 Kevin RODRIGUEZ Ecuador 0
#> 5 Kristijan JAKIC Croacia 0
#> 6 Predrag RAJKOVIC Serbia 0
#> 7 Antoine SEMENYO Ghana 0.5
#> 8 Jordan MORRIS Estados Unidos 0.5
#> 9 Abdul Fatawu ISSAHAKU Ghana 1
#> 10 HAITHAM ASIRI Arabia Saudí 1
Ok, ahora me los ordenó pero de menor a mayor. Yo los quiero justo al revés:
## Summarize
df.wc |>
group_by(player, NameTeam) |>
summarize(PassesMEAN = mean(Passes)) |>
arrange(desc(PassesMEAN)) |>
head(10)
#> `summarise()` has grouped output by 'player'. You can
#> override using the `.groups` argument.
#> # A tibble: 10 × 3
#> # Groups: player [10]
#> player NameTeam PassesMEAN
#> <chr> <chr> <dbl>
#> 1 Pau TORRES España 188
#> 2 RODRI España 165
#> 3 Aymeric LAPORTE España 144.
#> 4 Pedri GONZALEZ España 109.
#> 5 Marcos LLORENTE España 99
#> 6 John STONES Inglaterra 89.6
#> 7 Jordi ALBA España 89.2
#> 8 Cesar AZPILICUETA España 89
#> 9 Toby ALDERWEIRELD Bélgica 88.7
#> 10 Marcelo BROZOVIC Croacia 86.7
Listo, hemos conseguido el top 10 de jugadores con mejor promedio de pases por partido.
Me da mucha curiosidad analizarlo ahora por partido: el top 10 de jugadores que más pases hicieron en un mismo partido:
## Filter
df.wc |>
arrange(desc(Passes)) |>
head(10)
#> # A tibble: 10 × 15
#> player NameT…¹ match Passes Passe…² Fouls…³ Fouls…⁴
#> <chr> <chr> <chr> <dbl> <dbl> <dbl> <dbl>
#> 1 RODRI España MAR … 212 204 1 2
#> 2 RODRI España JPN … 211 205 0 1
#> 3 Pau TORRES España JPN … 188 173 0 1
#> 4 Aymeric LAP… España MAR … 184 179 0 1
#> 5 Pedri GONZA… España MAR … 150 138 2 1
#> 6 Aymeric LAP… España ESP … 146 141 1 2
#> 7 Rodrigo DE … Argent… POL … 146 139 1 2
#> 8 RODRI España ESP … 137 137 0 0
#> 9 Pedri GONZA… España JPN … 137 120 1 1
#> 10 Marcelo BRO… Croacia JPN … 125 113 0 1
#> # … with 8 more variables: TopSpeed <dbl>,
#> # TotalDistance <dbl>, Goals <dbl>, TimePlayed <dbl>,
#> # DefensivePressuresApplied <dbl>,
#> # OffersToReceiveTotal <dbl>,
#> # ReceptionsUnderNoPressure <dbl>,
#> # ReceptionsUnderPressure <dbl>, and abbreviated variable
#> # names ¹NameTeam, ²PassesCompleted, ³FoulsFor, …
#> # ℹ Use `colnames()` to see all variable names
Es cierto que este cálculo está sesgado por el hecho de que algunos de esos partidos contaron con tiempos extra (a mayor tiempo de juego, mayor posibilidad de hacer más pases, claro está), pero el top está suficientemente repartido (si mal no recuerdo, en seis de esos diez partidos no se jugaron tiempos extra). Pronto aprenderemos que hay variables que sí están muy sesgadas por el tiempo.
Aprovechando que ya habíamos calculado el promedio de pases por partido de Mbappé, recortemos la fracción del ranking en la que se encuentra, como para ver quiénes son sus vecinos:
## Filter
df.wc |>
group_by(player, NameTeam) |>
summarize(PassesMEAN = mean(Passes)) |>
arrange(desc(PassesMEAN)) |>
filter(PassesMEAN > 26 & PassesMEAN < 28)
#> `summarise()` has grouped output by 'player'. You can
#> override using the `.groups` argument.
#> # A tibble: 15 × 3
#> # Groups: player [15]
#> player NameTeam PassesMEAN
#> <chr> <chr> <dbl>
#> 1 Nicolas TAGLIAFICO Argentina 27.8
#> 2 Kylian MBAPPE Francia 27.7
#> 3 Leroy SANE Alemania 27.5
#> 4 Samuel GOUET Camerún 27.5
#> 5 Angel DI MARIA Argentina 27.4
#> 6 Guillermo VARELA Uruguay 27.3
#> 7 Abolfazl JALALI Irán 27
#> 8 Bryan OVIEDO Costa Rica 27
#> 9 Damian MARTINEZ Argentina 27
#> 10 Monir EL KAJOUI Marruecos 27
#> 11 Jawad EL YAMIQ Marruecos 26.8
#> 12 Sergio ROCHET Uruguay 26.7
#> 13 Tajon BUCHANAN Canadá 26.7
#> 14 Mehdi TAREMI Irán 26.3
#> 15 Christian PULISIC Estados Unidos 26.2
Tomando en cuenta la distribución de esa variable, a Mbappé claramente no le gusta pasarla:
## Get summary
df.wc |>
group_by(player, NameTeam) |>
summarize(PassesMEAN = mean(Passes)) |>
select(PassesMEAN) |>
summary()
#> `summarise()` has grouped output by 'player'. You can
#> override using the `.groups` argument.
#> Adding missing grouping variables: `player`
#> player PassesMEAN
#> Length:681 Min. : 0.00
#> Class :character 1st Qu.: 11.67
#> Mode :character Median : 24.00
#> Mean : 29.02
#> 3rd Qu.: 42.00
#> Max. :188.00
El promedio de pases por partido de Mbappé está por debajo del promedio-del-promedio de pases por partido de todos los jugadores. Considerando que para pasarla primero hay que tenerla, y que Mbappé es un jugador descomunal que a cada rato tiene la bola, su propensión a no soltarla se torna aún más significativa.
Ahora supongamos que quiero efectuar este mismo análisis pero por equipos. El top 5 de selecciones con mejor promedio de pases se ve así:
## Filter
df.wc |>
group_by(NameTeam) |>
summarize(PassesMEAN = mean(Passes)) |>
arrange(desc(PassesMEAN)) |>
head(5)
#> # A tibble: 5 × 2
#> NameTeam PassesMEAN
#> <chr> <dbl>
#> 1 España 59.7
#> 2 Inglaterra 39.6
#> 3 Alemania 39.6
#> 4 Croacia 39.2
#> 5 Dinamarca 38.7
Sorprendentemente, en el top 5 no están las selecciones finalistas (pese a que Argentina jugó varias veces tiempos extra). Veamos si aparecen usando una variable más exigente: número de pases completos.
## Filter
df.wc |>
group_by(NameTeam) |>
summarize(PassesCompletedMEAN = mean(PassesCompleted)) |>
arrange(desc(PassesCompletedMEAN)) |>
head(5)
#> # A tibble: 5 × 2
#> NameTeam PassesCompletedMEAN
#> <chr> <dbl>
#> 1 España 55.1
#> 2 Inglaterra 35.4
#> 3 Alemania 35.1
#> 4 Croacia 34.2
#> 5 Argentina 34.0
Ok, ya por lo menos encontré a Argentina. Me imagino que Francia sí ha de estar en el top 10, al menos:
## Filter
df.wc |>
group_by(NameTeam) |>
summarize(PassesCompletedMEAN = mean(PassesCompleted)) |>
arrange(desc(PassesCompletedMEAN)) |>
head(10)
#> # A tibble: 10 × 2
#> NameTeam PassesCompletedMEAN
#> <chr> <dbl>
#> 1 España 55.1
#> 2 Inglaterra 35.4
#> 3 Alemania 35.1
#> 4 Croacia 34.2
#> 5 Argentina 34.0
#> 6 Dinamarca 34.0
#> 7 Brasil 33.7
#> 8 Bélgica 33.4
#> 9 Portugal 32.7
#> 10 Países Bajos 31.3
Dios mío, no está Francia en el top 10. Parece que lo de Mbappé es más generalizado. ¿Está Francia en el top 15?
## Filter
df.wc |>
group_by(NameTeam) |>
summarize(PassesCompletedMEAN = mean(PassesCompleted)) |>
arrange(desc(PassesCompletedMEAN)) |>
head(15)
#> # A tibble: 15 × 2
#> NameTeam PassesCompletedMEAN
#> <chr> <dbl>
#> 1 España 55.1
#> 2 Inglaterra 35.4
#> 3 Alemania 35.1
#> 4 Croacia 34.2
#> 5 Argentina 34.0
#> 6 Dinamarca 34.0
#> 7 Brasil 33.7
#> 8 Bélgica 33.4
#> 9 Portugal 32.7
#> 10 Países Bajos 31.3
#> 11 Francia 30.4
#> 12 Estados Unidos 28.2
#> 13 Canadá 26.8
#> 14 República de Corea 26.8
#> 15 Catar 25.6
Por fin, ahí está Francia en la posición 11… En conclusión, los franceses se odian no hace falta tirar mucho pase para llegar hasta la final de una Copa Mundial de Fútbol y empatarla.
¡Y no me van a creer qué selección está en el anti-top de equipos con peor promedio de pases!
## Filter
df.wc |>
group_by(NameTeam) |>
summarize(PassesMEAN = mean(Passes)) |>
arrange(PassesMEAN) |>
head(3)
#> # A tibble: 3 × 2
#> NameTeam PassesMEAN
#> <chr> <dbl>
#> 1 Irán 19.6
#> 2 Costa Rica 21.7
#> 3 Arabia Saudí 23.9
## Filter
df.wc |>
group_by(NameTeam) |>
summarize(PassesCompletedMEAN = mean(PassesCompleted)) |>
arrange(PassesCompletedMEAN) |>
head(3)
#> # A tibble: 3 × 2
#> NameTeam PassesCompletedMEAN
#> <chr> <dbl>
#> 1 Irán 14.7
#> 2 Costa Rica 17.2
#> 3 Arabia Saudí 18.7
Hasta aquí, R nos ha permitido tomar un conjunto de datos y producir análisis en tres niveles diferentes: (1) Estadísticas para cada jugador en cada partido; (2) Estadísticas agregadas para cada jugador; (3) Estadísticas agregadas para cada equipo.
Sigamos prodigando ejemplos de cada nivel de análisis.
Emplear los datos de cada jugador para cada partido nos permite identificar cuáles son los tres futbolistas que más faltas recibieron en un mismo juego, un ranking de honor en el que, por supuesto, es de esperar que encontremos a los talentos especiales, tres mega cracks del fútbol mundial:
## Filter
df.wc |>
select(player, match, FoulsFor) |>
arrange(desc(FoulsFor)) |>
head(3)
#> # A tibble: 3 × 3
#> player match FoulsFor
#> <chr> <chr> <dbl>
#> 1 NEYMAR BRA - SRB 9
#> 2 Gerson TORRES JPN - CRC 8
#> 3 Lionel MESSI NED - ARG 8
Agrupar por jugador (por jugador y por selección al mismo tiempo, como expliqué antes, para evitar la sorpresa de que en algún equipo haya un jugador con el mismo nombre que otro jugador de otro equipo) nos permite identificar cuál jugador cometió más faltas en total (controlando por la cantidad de tiempo que jugó; hay que controlar por el tiempo pues los jugadores que jugaron más partidos y más tiempos extra naturalmente tendrán números al alza en faltas cometidas).
Primero voy a excluir del análisis a los jugadores que hayan estado 15 minutos o menos en la cancha:
## Filter
df.player <- df.wc |>
filter(TimePlayed > 15)
Siempre es bueno poner atención a cuántas observaciones perdemos después de decisiones de esa naturaleza:
A ver si es cierto:
Ahora sí, procedamos con nuestro análisis de faltas cometidas (en proporción con el tiempo de juego):
## Filter
df.player |>
group_by(player, NameTeam) |>
summarize(FoulsAgainstSUM = sum(FoulsAgainst),
TimePlayedSUM = sum(TimePlayed)) |>
mutate(FoulsAgainstMEAN = FoulsAgainstSUM / TimePlayedSUM) |>
arrange(desc(FoulsAgainstMEAN)) |>
select(FoulsAgainstMEAN, everything()) |>
head(10)
#> `summarise()` has grouped output by 'player'. You can
#> override using the `.groups` argument.
#> # A tibble: 10 × 5
#> # Groups: player [10]
#> FoulsAgainstMEAN player NameT…¹ Fouls…² TimeP…³
#> <dbl> <chr> <chr> <dbl> <dbl>
#> 1 0.0909 German PEZZELLA Argent… 3 33
#> 2 0.0909 Wout WEGHORST Países… 3 33
#> 3 0.0704 Walid CHEDDIRA Marrue… 5 71
#> 4 0.0678 Fabian RIEDER Suiza 4 59
#> 5 0.0652 Hiroki ITO Japón 3 46
#> 6 0.0645 PAIK Seungho Repúbl… 2 31
#> 7 0.0625 Ante BUDIMIR Croacia 3 48
#> 8 0.0612 Eray COMERT Suiza 3 49
#> 9 0.0556 DeAndre YEDLIN Estado… 2 36
#> 10 0.0556 Ghaylen CHAALELI Túnez 1 18
#> # … with abbreviated variable names ¹NameTeam,
#> # ²FoulsAgainstSUM, ³TimePlayedSUM
Sin controlar por el tiempo de juego, el ranking se vería así:
## Filter
df.player |>
group_by(player, NameTeam) |>
summarize(FoulsAgainstSUM = sum(FoulsAgainst)) |>
arrange(desc(FoulsAgainstSUM)) |>
select(FoulsAgainstSUM, everything()) |>
head(10)
#> `summarise()` has grouped output by 'player'. You can
#> override using the `.groups` argument.
#> # A tibble: 10 × 3
#> # Groups: player [10]
#> FoulsAgainstSUM player NameTeam
#> <dbl> <chr> <chr>
#> 1 17 Denzel DUMFRIES Países Bajos
#> 2 17 Jurrien TIMBER Países Bajos
#> 3 14 Sofyan AMRABAT Marruecos
#> 4 13 Hakim ZIYECH Marruecos
#> 5 13 Julian ALVAREZ Argentina
#> 6 13 Luka MODRIC Croacia
#> 7 12 Cristian ROMERO Argentina
#> 8 12 Nicolas OTAMENDI Argentina
#> 9 11 JUNG Wooyoung República de Corea
#> 10 11 Marcelo BROZOVIC Croacia
Sin controlar por el tiempo, el ranking acaba dominado por jugadores de equipos que llegaron más lejos en el Mundial y que disputaron tiempos extra.
Por último, agrupar los datos por selección hace posible revelar, por ejemplo, cuáles conjuntos hicieron más faltas en promedio.
El ranking sin controlar por el tiempo se ve así:
## Prepare data
df.team <- df.wc |>
group_by(NameTeam) |>
summarize(FoulsAgainstSUM = sum(FoulsAgainst))
## Filter
df.team |>
arrange(desc(FoulsAgainstSUM))
#> # A tibble: 32 × 2
#> NameTeam FoulsAgainstSUM
#> <chr> <dbl>
#> 1 Argentina 100
#> 2 Marruecos 97
#> 3 Croacia 90
#> 4 Países Bajos 87
#> 5 Francia 69
#> 6 Brasil 63
#> 7 Japón 58
#> 8 Arabia Saudí 56
#> 9 Portugal 56
#> 10 Inglaterra 53
#> # … with 22 more rows
#> # ℹ Use `print(n = ...)` to see more rows
De nuevo, si no controlamos por el tiempo, el ranking acaba dominado por los equipos que jugaron más partidos.
Para controlar por el tiempo, esta vez vamos a dividir el número total de faltas cometidas por el número de partidos que jugó el equipo respetivo. Esto requerirá crear una variable nueva en df.team
como la que habíamos creado en df.wc
.
Pero antes querré revisar un punto muy importante: ¿Holanda o Países Bajos? ¿República de Corea o Corea del Sur? Voy a fijarme en cómo están escritos los nombres porque si no los escribo exactamente igual, mi código no va a funcionar:
## Get team names
sort(unique(df.wc$NameTeam))
#> [1] "Alemania" "Arabia Saudí"
#> [3] "Argentina" "Australia"
#> [5] "Bélgica" "Brasil"
#> [7] "Camerún" "Canadá"
#> [9] "Catar" "Costa Rica"
#> [11] "Croacia" "Dinamarca"
#> [13] "Ecuador" "España"
#> [15] "Estados Unidos" "Francia"
#> [17] "Gales" "Ghana"
#> [19] "Inglaterra" "Irán"
#> [21] "Japón" "Marruecos"
#> [23] "México" "Países Bajos"
#> [25] "Polonia" "Portugal"
#> [27] "República de Corea" "Senegal"
#> [29] "Serbia" "Suiza"
#> [31] "Túnez" "Uruguay"
Ahora sí, vamos a crear la nueva variable con un for loop que rastree a cada selección según su nombre exacto:
## Create variable
df.team$matches <- 999
## Inspect
table(df.team$matches)
#>
#> 999
#> 32
## Adjust variable
for(i in 1:nrow(df.team)){
if(df.team$NameTeam[i] %in% c("Argentina", "Francia",
"Croacia", "Marruecos")){
df.team$matches[i] <- 7
} else if(df.team$NameTeam[i] %in% c("Países Bajos", "Brasil",
"Inglaterra", "Portugal")){
df.team$matches[i] <- 5
} else if(df.team$NameTeam[i] %in% c("Estados Unidos", "Australia",
"Japón", "República de Corea",
"Senegal", "Polonia",
"España", "Suiza")){
df.team$matches[i] <- 4
} else{
df.team$matches[i] <- 3
}
}
## Inspect
table(df.team$matches)
#>
#> 3 4 5 7
#> 16 8 4 4
Y ahora sí podemos construir el ranking de las selecciones con pata más pesada:
## Filter
df.team |>
select(NameTeam, matches, FoulsAgainstSUM) |>
mutate(FoulsAgainstSUM.MEAN = FoulsAgainstSUM / matches) |>
arrange(desc(FoulsAgainstSUM.MEAN)) |>
select(FoulsAgainstSUM.MEAN, everything()) |>
head(10)
#> # A tibble: 10 × 4
#> FoulsAgainstSUM.MEAN NameTeam matches FoulsAgainstSUM
#> <dbl> <chr> <dbl> <dbl>
#> 1 18.7 Arabia Saudí 3 56
#> 2 17.4 Países Bajos 5 87
#> 3 17 México 3 51
#> 4 16.7 Ecuador 3 50
#> 5 15.3 Ghana 3 46
#> 6 14.5 Japón 4 58
#> 7 14.3 Serbia 3 43
#> 8 14.3 Argentina 7 100
#> 9 13.9 Marruecos 7 97
#> 10 13.3 Túnez 3 40
A veces la media puede ser muy engañosa. ¿Cuál fue el promedio de goles anotados entre Kylian Mbappé, Keysher Fuller, Johan Venegas, Anthony Contreras, Joel Campbell y Jewison Bennette?
## Compute
df.wc |>
group_by(player) |>
filter(player == "Kylian MBAPPE" |
player == "Keysher FULLER" |
player == "Johan VENEGAS" |
player == "Anthony CONTRERAS" |
player == "Joel CAMPBELL" |
player == "Jewison BENNETTE") |>
summarize(GoalsSUM = sum(Goals)) |>
pull(GoalsSUM) |>
mean()
#> [1] 1.5
En promedio, tendríamos que afirmar que Mbappé, Fuller, Venegas, Contreras, Campbell y Bennette hicieron un gol y medio cada uno… Y esas serían cifras excelentes para los ticos, pero la verdad es que únicamente Fuller anotó y el promedio parece mejor de lo que es sólo porque el goleo de Mbappé (que es más bueno y jugó más partidos) le está mejorando los números a todos.
Claramente, la media es una mala descripción de ese pedacito de los datos. La mediana, en cambio, arroja una descripción más precisa de un vector que de todas formas se ve así: {0, 0, 0, 0, 1, 8}
.
## Compute
df.wc |>
group_by(player) |>
filter(player == "Kylian MBAPPE" |
player == "Keysher FULLER" |
player == "Johan VENEGAS" |
player == "Anthony CONTRERAS" |
player == "Joel CAMPBELL" |
player == "Jewison BENNETTE") |>
summarize(GoalsSUM = sum(Goals)) |>
pull(GoalsSUM) |>
median()
#> [1] 0
Por algo se dice que la mediana es más robusta que la media: a la mediana no la jalan los valores atípicos (outliers), como la capacidad goleadora de Kylian Mbappé.
11.2 Valores atípicos
Hemos agrupado por jugador y por equipo, pero también podríamos agrupar por partido. ¿Cuán escandalosa fue la goleada que le metió España a Costa Rica tomando en cuenta la cantidad promedio de goles por partido?
## Prepare data
df.goals <- df.wc |>
group_by(match) |>
summarize(GoalsSUM = sum(Goals)) |>
arrange(desc(GoalsSUM))
Ya veremos que la respuesta es: muy escandalosa.
Estos son los partidos con más goles de este Mundial:
## Rank
head(df.goals, 6)
#> # A tibble: 6 × 2
#> match GoalsSUM
#> <chr> <dbl>
#> 1 ENG - IRN 8
#> 2 ESP - CRC 7
#> 3 POR - SUI 7
#> 4 ARG - FRA 6
#> 5 CMR - SRB 6
#> 6 CRC - GER 6
El promedio de goles por partido fue este:
## Compute
mean(df.goals$GoalsSUM)
#> [1] 2.65625
¿Cuán atípicos fueron los partidos de siete goles, dados estos números? Basta una visualización:
## Prepare data
y <- df.goals$GoalsSUM
df <- data.frame(
x = 1,
mean = mean(y),
sd = sd(y)
)
## Plot
ggplot(df, aes(x)) +
geom_boxplot(
aes(
x = x,
ymin = (mean - 3 * sd),
lower = (mean - sd),
middle = mean,
upper = (mean + sd),
ymax = (mean + 3 * sd)),
stat = "identity"
) +
scale_x_discrete() +
geom_jitter(
data = df.goals,
aes(
y = GoalsSUM, x = "", color = factor(GoalsSUM)),
height = 0.1,
width = 0.04,
shape = 5,
size = 4.5
) +
scale_y_continuous(breaks = 0:8) +
geom_hline(yintercept = mean(df.goals$GoalsSUM),
color = "red",
linewidth = 1.5) +
geom_hline(yintercept = mean(df.goals$GoalsSUM) + sd(df.goals$GoalsSUM),
color = "blue",
linewidth = 1) +
geom_hline(yintercept = mean(df.goals$GoalsSUM) + 2 * sd(df.goals$GoalsSUM),
color = "blue",
linewidth = 0.3) +
geom_hline(yintercept = mean(df.goals$GoalsSUM) - sd(df.goals$GoalsSUM),
color = "blue",
linewidth = 1) +
geom_hline(yintercept = mean(df.goals$GoalsSUM) - 2 * sd(df.goals$GoalsSUM),
color = "blue",
linewidth = 0.3) +
theme_gray() +
ggtitle("Goles anotados por partido") +
ylab("Goles") +
theme(legend.position = "none",
axis.title.x = element_blank())
A ese gráfico horrible le imprimí encima una línea roja para indicar cuál es el promedio de goles por partido y varias líneas azules que marcan, cada una, una unidad estándar adicional por encima o por debajo del promedio.
El gráfico nos permite concluir que la ocurrencia de un partido con siete goles fue algo súper inusual en este Mundial (y no digamos ya que los siete goles se los haya comido todos el mismo equipo).
Si le quito esas líneas sigue siendo una visualización muy humilde, pero menos fea:
## Prepare data
y <- df.goals$GoalsSUM
df <- data.frame(
x = 1,
mean = mean(y),
sd = sd(y)
)
## Plot
ggplot(df, aes(x)) +
geom_boxplot(
aes(
x = x,
ymin = (mean - 3 * sd),
lower = (mean - sd),
middle = mean,
upper = (mean + sd),
ymax = (mean + 3 * sd)),
stat = "identity"
) +
scale_x_discrete() +
geom_jitter(
data = df.goals, aes(y = GoalsSUM, x = "", color = factor(GoalsSUM)),
height = 0.1,
width = 0.04,
shape = 5,
size = 4.5
) +
scale_y_continuous(breaks = 0:8) +
theme_gray() +
ggtitle("Goles anotados por partido") +
ylab("Goles") +
theme(legend.position = "none",
axis.title.x = element_blank())
Aprecien también que a los cuadrados les metí una vibración -aleatoria, por eso cambian de lugar- para que no quedaran todos encimados, como los de acá abajo:
## Prepare data
y <- df.goals$GoalsSUM
df <- data.frame(
x = 1,
mean = mean(y),
sd = sd(y)
)
## Plot
ggplot(df, aes(x)) +
geom_boxplot(
aes(
x = x,
ymin = (mean - 3 * sd),
lower = (mean - sd),
middle = mean,
upper = (mean + sd),
ymax = (mean + 3 * sd)),
stat = "identity"
) +
scale_x_discrete() +
geom_point(data = df.goals, aes(y = GoalsSUM, x = "", color = factor(GoalsSUM)),
height = 0.1,
width = 0.04,
shape = 5,
size = 4.5) +
scale_y_continuous(breaks = 0:8) +
theme_gray() +
ggtitle("Goles anotados por partido") +
ylab("Goles") +
theme(legend.position = "none",
axis.title.x = element_blank())
#> Warning in geom_point(data = df.goals, aes(y = GoalsSUM,
#> x = "", color = factor(GoalsSUM)), : Ignoring unknown
#> parameters: `height` and `width`
Dejo como tarea que cada quien analice qué fue lo que cambié en cada bloque de código para que las visualizaciones fueran distintas.
Arribá confié en la media y la desviación estandar para identificar valores atípicos (outliers, en inglés). El código de abajo crea una visualización que cumple el mismo objetivo pero usando la mediana y los cuartiles.
Observen que la media y la mediana son diferentes. Es normal que los hallazgos de este análisis sean diferentes también:
Visualicemos los datos mediante un box plot:
set.seed(1) # Just ignore this
## Plot
boxplot(df.goals$GoalsSUM)
stripchart(df.goals$GoalsSUM,
method = "jitter",
pch = 19,
col = 4,
vertical = TRUE,
add = TRUE)
Arriba se me coló un punto blanco que sin duda querré eliminar; igualmente, voy a organizar mejor el eje Y:
## Plot
boxplot(df.goals$GoalsSUM, outcol = "white")
stripchart(df.goals$GoalsSUM,
method = "jitter",
pch = 19,
col = 4,
vertical = TRUE,
add = TRUE)
axis(2, at = seq(0, 8, 1))
Ya eliminé el punto blanco (los puntos cambiaron un poco de lugar porque los estoy impriendo con una vibración para que no queden uno sobre el otro, y dicha vibración R la introduce aleatoriamente).
Vamos a explorar un poco estos resultados. Primero, preguntémonos cuál es ese punto que quedó fuera del box plot. Corramos el siguiente código para que R nos señale, en el conjunto de datos, cuál es ese outlier exactamente:
## Get outliers
boxplot(df.goals$GoalsSUM, plot = FALSE)$out
#> [1] 8
## Inspect outliers
df.goals |>
filter(GoalsSUM %in% (boxplot(df.goals$GoalsSUM, plot = FALSE)$out))
#> # A tibble: 1 × 2
#> match GoalsSUM
#> <chr> <dbl>
#> 1 ENG - IRN 8
Ahora intentemos entender la matemática del box plot, a la que podemos accesar de la siguiente forma:
## Get statistics
boxplot(df.goals$GoalsSUM, plot = FALSE)$stats
#> [,1]
#> [1,] 0.0
#> [2,] 1.0
#> [3,] 2.0
#> [4,] 3.5
#> [5,] 7.0
Vamos a empezar por lo más fácil. El box plot parte los datos por la mitad, los parte en fracciones iguales, justo a la altura de la mediana:
## Get the median
boxplot(df.goals$GoalsSUM, plot = FALSE)$stats[3, 1]
#> [1] 2
## Verify
median(df.goals$GoalsSUM)
#> [1] 2
## Verify
quantile(df.goals$GoalsSUM, probs = c(0, 0.25, 0.5, 0.75, 1))[3]
#> 50%
#> 2
La mediana ubica dónde está el valor que divide los datos en dos mitades: 50%. El box plot incluye además los valores que dividen el 25% y el 75% de los datos, y los utiliza a ambos para dibujar la caja:
## Get the lower quartile
boxplot(df.goals$GoalsSUM, plot = FALSE)$stats[2, 1]
#> [1] 1
## Verify
quantile(df.goals$GoalsSUM, probs = c(0, 0.25, 0.5, 0.75, 1))[2]
#> 25%
#> 1
## Get the upper quartile
boxplot(df.goals$GoalsSUM, plot = FALSE)$stats[4, 1]
#> [1] 3.5
## Verify
quantile(df.goals$GoalsSUM, probs = c(0, 0.25, 0.5, 0.75, 1))[4]
#> 75%
#> 3.25
Si miran con cuidado, verán que hay una pequeña discrepancia respecto a cuál es el valor que marca el 75% de los datos, según cuál sea la función que utilizemos para el cálculo. Aunque el por qué de esa diferencia no sería difícil de explicar, vamos a ignorarla salvo para ilustrar algo interesante -y a veces irritante- sobre R: si bien es genial que existan varias funciones para efectuar un mismo cálculo, a veces los algoritmos de esas funciones pueden ser sutilmente distintos y, por ende, dar paso a resultados inconsistentes.
Voy a usar otra función (geom_boxplot()
) de otro paquete (ggplot2
) para visualizar los datos otra vez, con la diferencia de que esta vez voy a cargar menos el box plot. Además, voy a marcar con líneas de color los límites de la caja tal cual los ubicaría la función boxplot()
para ilustrar que, a veces, funciones que se suponen hacen lo mismo pueden seguir recetas ligeramente diferentes:
## Plot
ggplot(df.goals, aes (x = GoalsSUM)) +
geom_boxplot() +
geom_vline(xintercept = boxplot(df.goals$GoalsSUM, plot = FALSE)$stats[4, 1],
color = "purple") +
geom_vline(xintercept = boxplot(df.goals$GoalsSUM, plot = FALSE)$stats[2, 1],
color = "green")
El próximo gráfico es una demostración de cómo la función geom_boxplot()
estima los cuantiles del 25% y el 75% de los datos de forma consistente con la función quantile()
:
## Plot
ggplot(df.goals, aes (x = GoalsSUM)) +
geom_boxplot() +
geom_vline(xintercept = quantile(df.goals$GoalsSUM, 0.75),
color = "purple") +
geom_vline(xintercept = quantile(df.goals$GoalsSUM, 0.25),
color = "green")
Vamos a seguir trabajando con base en el gráfico de arriba. Ya dijimos que la mediana (en este caso, la línea negra dentro de la caja: 2) divide los datos justo en dos mitades: la mitad de los partidos con menos goles y la mitad de los partidos con más goles.
Otra forma de partir los datos en mitades es tomando en cuenta toda la caja que se forma justo entre las líneas verde y morada. La caja representa la mitad de los datos más cercana a la mediana, mientras el cuarto que queda por fuera a la izquierda y el cuarto que queda por fuera a la derecha suman la otra mitad: la mitad de los datos que están más lejos de la mediana.
La distancia que hay entre las líneas verde y morada se llama el rango intercuartílico:
## Compute
IQR(df.goals$GoalsSUM)
#> [1] 2.25
## Verify
quantile(df.goals$GoalsSUM, probs = c(0.75)) - quantile(df.goals$GoalsSUM, probs = c(0.25)) == IQR(df.goals$GoalsSUM)
#> 75%
#> TRUE
De modo que el rango intercuartílico es igual a la longitud de la caja, y la caja es, tal como ya lo expliqué, la región que concentra la mitad de los datos más próximos a la mediana.
De izquierda a derecha, la línea negra marca el 50% de los datos; la línea verde, el 25% de los datos; la línea morada, el 75% de los datos.
La horizontal negra que sale de la línea verde tendría una extensión de \(1.5 * (IQR)\) pero R la corta en 0 porque de formas el conjunto de datos no tiene observaciones después de 0 (no hay partidos que acaben con -1 goles); a su vez, la horizontal negra que sale de la línea morada tendría una extensión de \(1.5 * (IQR)\) pero R la corta en 6.
La extensión real que deberían tener las horizontales negras como resultado de \(1.5 * (IQR)\) se muestra en seguida:
## Plot
ggplot(df.goals, aes (x = GoalsSUM)) +
geom_boxplot() +
geom_vline(xintercept = quantile(df.goals$GoalsSUM, probs = c(0, 0.25, 0.5, 0.75, 1))[4] + 1.5 * (IQR(df.goals$GoalsSUM)),
color = "red") +
geom_vline(xintercept = quantile(df.goals$GoalsSUM, probs = c(0, 0.25, 0.5, 0.75, 1))[2] - 1.5 * (IQR(df.goals$GoalsSUM)),
color = "red")
La importancia de todo este código es que también nos permite ubicar los outliers, es decir, los datos ubicados más allá de \(1.5 * IQR\) respecto a los límites de la caja:
set.seed(1) # Just ignore this
## Plot
ggplot(df.goals, aes(x = "", y = GoalsSUM)) +
geom_boxplot(outlier.shape = NA) +
geom_jitter(data = subset(df.goals, GoalsSUM >= quantile(df.goals$GoalsSUM, probs = c(0.75)) + 1.5 * IQR(df.goals$GoalsSUM)),
aes(x = "", y = GoalsSUM, color = match),
stat = "identity",
width = 0.05,
height = 0.1) +
scale_y_continuous(breaks = 0:8) +
xlab("")
Sólo el partido entre Inglaterra e Irán (6-2) tuvo más goles que el de España y Costa Rica (7-0), pero los iraníes al menos no se fueron en blanco…
Ahora permítanme un comentario más específico sobre la Sele. Yo puedo entender que no tengamos un delantero que meta los goles que mete Mbappé ni un volante que tire los pases que tira De Paul. Yo soy realista y sólo espero dos actitutes: que corran y que se apliquen en defensa (quiero decir, que vayan a presionar al rival que tiene la bola).
Vamos a comparar a los jugadores de la Sele entre sí con base en cuánto se movieron y cuánto presionaron. Para que las comparaciones que planeo realizar en breve sean más justas, seguiré trabajando únicamente con los jugadores que en el partido respectivo participaron durante más de quince minutos.
Este conjunto de datos tiene una variable para indicar cuánta distancia cubrió cada jugador para cada partido (TotalDistance
), una para medir cuántas veces presionó a un rival (DefensivePressuresApplied
), y otra para cuántos minutos jugó (TimePlayed
).
Dado que la distancia recorrida y las acciones de presión defensiva dependen del tiempo en campo, voy a crear variables nuevas que las expresen en proporción con el tiempo en cancha:
## Filter
df.min <- df.player |>
mutate(TotalDistanceOverTime = TotalDistance / TimePlayed,
DefensivePressuresAppliedOverTime = DefensivePressuresApplied / TimePlayed)
Exploremos las variables recién creadas:
## Explore
summary(df.min$DefensivePressuresAppliedOverTime)
#> Min. 1st Qu. Median Mean 3rd Qu. Max.
#> 0.0000 0.1250 0.2595 0.2924 0.4082 1.6000
summary(df.min$TotalDistanceOverTime)
#> Min. 1st Qu. Median Mean 3rd Qu. Max. NA's
#> 20.86 100.60 110.31 108.51 120.43 201.80 83
¿Por qué TotalDistanceOverTime
tiene NA
? Ya habíamos aprendido cómo buscar esos casos:
## Inspect NA
df.min[c(which(is.na(df.min$TotalDistanceOverTime))), ]
#> # A tibble: 83 × 17
#> player NameT…¹ match Passes Passe…² Fouls…³ Fouls…⁴
#> <chr> <chr> <chr> <dbl> <dbl> <dbl> <dbl>
#> 1 Mehdi TAREMI Irán WAL … 25 17 3 0
#> 2 Harry WILSON Gales WAL … 23 18 1 0
#> 3 Connor ROBE… Gales WAL … 35 27 0 0
#> 4 Brennan JOH… Gales WAL … 7 5 0 0
#> 5 Aaron RAMSEY Gales WAL … 51 39 1 0
#> 6 Joe RODON Gales WAL … 52 50 0 1
#> 7 Gareth BALE Gales WAL … 22 16 1 0
#> 8 Alphonso DA… Canadá CRO … 39 31 2 1
#> 9 Cyle LARIN Canadá CRO … 8 5 0 0
#> 10 Tajon BUCHA… Canadá CRO … 33 22 2 1
#> # … with 73 more rows, 10 more variables: TopSpeed <dbl>,
#> # TotalDistance <dbl>, Goals <dbl>, TimePlayed <dbl>,
#> # DefensivePressuresApplied <dbl>,
#> # OffersToReceiveTotal <dbl>,
#> # ReceptionsUnderNoPressure <dbl>,
#> # ReceptionsUnderPressure <dbl>,
#> # TotalDistanceOverTime <dbl>, …
#> # ℹ Use `print(n = ...)` to see more rows, and `colnames()` to see all variable names
Evaluemos si hay algún jugador de Costa Rica entre esos casos:
## Count
table(df.min[c(which(is.na(df.min$TotalDistanceOverTime))), ][, 2])
#> NameTeam
#> Canadá Croacia Francia
#> 15 13 9
#> Gales Ghana Inglaterra
#> 6 14 10
#> Irán República de Corea Serbia
#> 1 13 2
Por el momento no voy a removerlos porque ningún jugador de Costa Rica presenta tal problema. Pero háganme el favor de apreciar qué útil este code drill:
## Count NA
df.min[c(which(is.na(df.min$TotalDistanceOverTime))), ] |> nrow()
#> [1] 83
## Inspect NA
df.min[c(which(is.na(df.min$TotalDistanceOverTime))), ] |> head()
#> # A tibble: 6 × 17
#> player NameT…¹ match Passes Passe…² Fouls…³ Fouls…⁴
#> <chr> <chr> <chr> <dbl> <dbl> <dbl> <dbl>
#> 1 Mehdi TAREMI Irán WAL … 25 17 3 0
#> 2 Harry WILSON Gales WAL … 23 18 1 0
#> 3 Connor ROBER… Gales WAL … 35 27 0 0
#> 4 Brennan JOHN… Gales WAL … 7 5 0 0
#> 5 Aaron RAMSEY Gales WAL … 51 39 1 0
#> 6 Joe RODON Gales WAL … 52 50 0 1
#> # … with 10 more variables: TopSpeed <dbl>,
#> # TotalDistance <dbl>, Goals <dbl>, TimePlayed <dbl>,
#> # DefensivePressuresApplied <dbl>,
#> # OffersToReceiveTotal <dbl>,
#> # ReceptionsUnderNoPressure <dbl>,
#> # ReceptionsUnderPressure <dbl>,
#> # TotalDistanceOverTime <dbl>, …
#> # ℹ Use `colnames()` to see all variable names
## Remove NA
df.min[c(which(!is.na(df.min$TotalDistanceOverTime))), ] |> nrow()
#> [1] 1745
Casi se nos olvida darle un vistazo al data frame que estamos a punto de analizar:
## Explore data
skim(df.min) |> kable()
skim_type | skim_variable | n_missing | complete_rate | character.min | character.max | character.empty | character.n_unique | character.whitespace | numeric.mean | numeric.sd | numeric.p0 | numeric.p25 | numeric.p50 | numeric.p75 | numeric.p100 | numeric.hist |
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
character | player | 0 | 1.0000000 | 4 | 26 | 0 | 638 | 0 | NA | NA | NA | NA | NA | NA | NA | NA |
character | NameTeam | 0 | 1.0000000 | 4 | 18 | 0 | 32 | 0 | NA | NA | NA | NA | NA | NA | NA | NA |
character | match | 0 | 1.0000000 | 9 | 9 | 0 | 64 | 0 | NA | NA | NA | NA | NA | NA | NA | NA |
numeric | Passes | 0 | 1.0000000 | NA | NA | NA | NA | NA | 34.6739606 | 25.6400677 | 0.00000 | 15.0000 | 29.0000000 | 4.800000e+01 | 212.0000 | ▇▃▁▁▁ |
numeric | PassesCompleted | 0 | 1.0000000 | NA | NA | NA | NA | NA | 29.6745077 | 24.2111372 | 0.00000 | 12.0000 | 23.0000000 | 4.100000e+01 | 205.0000 | ▇▂▁▁▁ |
numeric | FoulsFor | 0 | 1.0000000 | NA | NA | NA | NA | NA | 0.8189278 | 1.1212885 | 0.00000 | 0.0000 | 0.0000000 | 1.000000e+00 | 9.0000 | ▇▂▁▁▁ |
numeric | FoulsAgainst | 0 | 1.0000000 | NA | NA | NA | NA | NA | 0.8550328 | 1.0874675 | 0.00000 | 0.0000 | 0.5000000 | 1.000000e+00 | 6.0000 | ▇▂▁▁▁ |
numeric | TopSpeed | 83 | 0.9545952 | NA | NA | NA | NA | NA | 30.4511691 | 2.8037429 | 17.64000 | 29.1600 | 30.8800000 | 3.230000e+01 | 35.6600 | ▁▁▂▇▅ |
numeric | TotalDistance | 83 | 0.9545952 | NA | NA | NA | NA | NA | 7915.9933754 | 3067.7861243 | 396.27000 | 5124.8600 | 8688.4700000 | 1.028888e+04 | 16639.6100 | ▃▅▇▆▁ |
numeric | Goals | 0 | 1.0000000 | NA | NA | NA | NA | NA | 0.0908096 | 0.3299712 | 0.00000 | 0.0000 | 0.0000000 | 0.000000e+00 | 3.0000 | ▇▁▁▁▁ |
numeric | TimePlayed | 0 | 1.0000000 | NA | NA | NA | NA | NA | 76.1296499 | 29.1109486 | 16.00000 | 54.7500 | 90.0000000 | 9.800000e+01 | 138.0000 | ▃▂▃▇▁ |
numeric | DefensivePressuresApplied | 0 | 1.0000000 | NA | NA | NA | NA | NA | 19.9917943 | 15.9117861 | 0.00000 | 8.0000 | 17.0000000 | 2.900000e+01 | 98.0000 | ▇▅▁▁▁ |
numeric | OffersToReceiveTotal | 0 | 1.0000000 | NA | NA | NA | NA | NA | 39.3490153 | 25.9890437 | 0.00000 | 19.0000 | 34.0000000 | 5.425000e+01 | 149.0000 | ▇▇▃▁▁ |
numeric | ReceptionsUnderNoPressure | 0 | 1.0000000 | NA | NA | NA | NA | NA | 22.8533917 | 22.3864979 | 0.00000 | 7.0000 | 16.0000000 | 3.225000e+01 | 195.0000 | ▇▂▁▁▁ |
numeric | ReceptionsUnderPressure | 0 | 1.0000000 | NA | NA | NA | NA | NA | 12.8873085 | 9.6343320 | 0.00000 | 6.0000 | 11.0000000 | 1.800000e+01 | 64.0000 | ▇▅▁▁▁ |
numeric | TotalDistanceOverTime | 83 | 0.9545952 | NA | NA | NA | NA | NA | 108.5132480 | 22.7921126 | 20.85632 | 100.6024 | 110.3088764 | 1.204333e+02 | 201.8003 | ▁▁▇▁▁ |
numeric | DefensivePressuresAppliedOverTime | 0 | 1.0000000 | NA | NA | NA | NA | NA | 0.2924232 | 0.2237571 | 0.00000 | 0.1250 | 0.2594998 | 4.082351e-01 | 1.6000 | ▇▅▁▁▁ |
Recapitulemos: estamos a punto de comparar la actuación de todos los jugadores en cada uno de los juegos en que participaron (siempre que lo hayan hecho por más de 15 minutos). Vamos a enfocarnos en dos variables: cuánta distancia recorrieron (TotalDistanceOverTime
) y cuántas veces presionaron defensivamente a un rival (DefensivePressuresAppliedOverTime
). Estas variables me parecen razonables indicadores de esfuerzo futbolístico mínimo: correr y marcar. Ambas variables están controladas por el tiempo que cada jugador participó en el respectivo partido, de modo que los resultados son comparables entre sí -con ciertas precauciones que ya casi discutiremos-.
Veamos cómo se comportó la Sele en estas métricas de -insisto- esfuerzo deportivo mínimo (correr y marcar):
## Plot
df.min |>
ggplot(aes(x = TotalDistanceOverTime,
y = DefensivePressuresAppliedOverTime)) +
geom_jitter(
color = ifelse(df.min$NameTeam == "Costa Rica", "black", "black"),
shape = ifelse(df.min$NameTeam == "Costa Rica", 0, 4),
size = ifelse(df.min$NameTeam == "Costa Rica", 2.5, 1.5),
alpha = 0.2) +
geom_vline(
xintercept = mean(df.min$TotalDistanceOverTime, na.rm = T),
color = "black") +
geom_hline(
yintercept = mean(df.min$DefensivePressuresAppliedOverTime),
color = "black") +
geom_point(data = subset(df.min, NameTeam == "Costa Rica"),
size = 2.5,
shape = 15,
aes(color = match)) +
geom_text_repel(data = subset(df.min, NameTeam == "Costa Rica"),
size = 3,
fontface = "bold",
hjust = 2,
position = position_jitter(),
aes(
TotalDistanceOverTime,
DefensivePressuresAppliedOverTime,
label = str_extract_all(player, "[A-Z]{2,}"),
color = match),
max.overlaps = Inf) +
theme_minimal()
#> Warning: Removed 83 rows containing missing values
#> (`geom_point()`).
Esta visualización hay que analizarla con cuidado, principalmente porque los jugadores no tienen funciones similares en el terreno de juego y es normal que esa especialización funcional se refleje en las métricas. Por ende, debemos comparar jugadores que se desempeñan en roles parecidos.
En esta visualización están todos los jugadores del Mundial (los que no son costarricenses son las equis que se miran al fondo). Algunos hechos destacables a simple vista: la agresividad con la que juegan Ronald Matarrita y Jewison Bennette contra Alemania (ingresaron de cambio, sin embargo, y eso ciertamente ayuda a conseguir mejores métricas), la altísima presión de Johan Venegas contra Alemania y, al contrario, la falta de ganas de Anthony Contreras contra España (en el partido contra España, Contreras tiene peores números que Bryan Ruiz, un tipo que desde que se montó al avión ya era un ex jugador), Joel no corre más que el promedio, etcétera.
Y si alguien quiere profundizar en la comparación entre Bryan Ruiz y Anthony Contreras en el partido contra España:
## Filter
df.wc |>
filter(player == "Anthony CONTRERAS" | player == "Bryan RUIZ") |>
filter(match == "ESP - CRC") |>
select(player, TimePlayed, Passes, PassesCompleted, FoulsFor, DefensivePressuresApplied, OffersToReceiveTotal)
#> # A tibble: 2 × 7
#> player TimeP…¹ Passes Passe…² Fouls…³ Defen…⁴ Offer…⁵
#> <chr> <dbl> <dbl> <dbl> <dbl> <dbl> <dbl>
#> 1 Bryan RUIZ 34 11 8 1 23 17
#> 2 Anthony CO… 66 4 3 0 41 8
#> # … with abbreviated variable names ¹TimePlayed,
#> # ²PassesCompleted, ³FoulsFor,
#> # ⁴DefensivePressuresApplied, ⁵OffersToReceiveTotal
En otro ejercicio, si agregamos las estadísticas por equipo -y controlamos por el número de partidos disputados-, podemos tener una buena estimación de cómo se comporta la capacidad goleadora en función de la generación de pases completos:
## Prepare data
df.team <- df.wc |>
group_by(NameTeam) |>
summarize(PassesCompletedSUM = sum(PassesCompleted),
GoalsSUM = sum(Goals))
## Create variable
df.team$matches <- 999
## Inspect
table(df.team$matches)
#>
#> 999
#> 32
## Fill variable
for(i in 1:nrow(df.team)){
if(df.team$NameTeam[i] %in% c("Argentina", "Francia",
"Croacia", "Marruecos")){
df.team$matches[i] <- 7
} else if(df.team$NameTeam[i] %in% c("Países Bajos", "Brasil",
"Inglaterra", "Portugal")){
df.team$matches[i] <- 5
} else if(df.team$NameTeam[i] %in% c("Estados Unidos", "Australia",
"Japón", "República de Corea",
"Senegal", "Polonia",
"España", "Suiza")){
df.team$matches[i] <- 4
} else{
df.team$matches[i] <- 3
}
}
## Inspect
table(df.team$matches)
#>
#> 3 4 5 7
#> 16 8 4 4
## Create variables
df.team <- df.team |>
mutate(PassesCompletedMEAN = PassesCompletedSUM / matches,
GoalsMEAN = GoalsSUM / matches)
Exploramos el data frame antes de darle uso:
## Explore data
head(df.team)
#> # A tibble: 6 × 6
#> NameTeam PassesCompl…¹ Goals…² matches Passe…³ Goals…⁴
#> <chr> <dbl> <dbl> <dbl> <dbl> <dbl>
#> 1 Alemania 1685 6 3 562. 2
#> 2 Arabia Saudí 881 3 3 294. 1
#> 3 Argentina 3841 15 7 549. 2.14
#> 4 Australia 1206 3 4 302. 0.75
#> 5 Bélgica 1568 1 3 523. 0.333
#> 6 Brasil 2696 8 5 539. 1.6
#> # … with abbreviated variable names ¹PassesCompletedSUM,
#> # ²GoalsSUM, ³PassesCompletedMEAN, ⁴GoalsMEAN
Y lo podemos graficar:
## Plot
ggplot(df.team,
aes(x = PassesCompletedMEAN,
y = GoalsMEAN,
label = NameTeam)) +
geom_point() +
geom_smooth(method = "lm",
se = T,
color = "blue") +
geom_text_repel(size = 3)
#> `geom_smooth()` using formula = 'y ~ x'
## Plot
ggplot(df.team,
aes(x = PassesCompletedMEAN,
y = GoalsMEAN,
label = NameTeam,
color = factor(matches))) +
geom_point(shape = 25) +
geom_smooth(method = "lm",
color = "black",
se = FALSE) +
geom_text_repel(size = 4) +
scale_color_manual(values = c("3" = "grey85", "4" = "grey60", "5" = "skyblue3", "7" = "blue")) +
theme_light() +
theme(legend.position = "none")
#> `geom_smooth()` using formula = 'y ~ x'
Se aprecia que los pases completos por sí sólos no son muy buenos predictores de los goles; la correlación entre ambas variables es positiva pero modesta:
## Compute
cor(df.team$PassesCompletedMEAN, df.team$GoalsMEAN)
#> [1] 0.482587
Si agregamos las estadísticas por jugador, podemos analizar si quiénes piden más la bola (OffersToReceiveMEAN
) y quiénes la reciben más (ReceptionsMEAN
). Aquí tendremos que controlar por el tiempo en acción:
## Prepare data
df.player <- df.player |>
mutate(ReceptionsSUM = ReceptionsUnderNoPressure + ReceptionsUnderPressure)
## Summarize
df.player <- df.player |>
group_by(player, NameTeam) |>
summarize(OffersToReceiveMEAN = median(OffersToReceiveTotal),
ReceptionsMEAN = median(ReceptionsSUM))
#> `summarise()` has grouped output by 'player'. You can
#> override using the `.groups` argument.
Revisemos el data frame antes de aprovecharlo:
## Explore data
df.player |>
arrange(desc(OffersToReceiveMEAN)) |>
head(20) |>
select(OffersToReceiveMEAN, everything())
#> # A tibble: 20 × 4
#> # Groups: player [20]
#> OffersToReceiveMEAN player NameTeam Recep…¹
#> <dbl> <chr> <chr> <dbl>
#> 1 129 Jude BELLINGHAM Inglaterra 47
#> 2 128. Pedri GONZALEZ España 113
#> 3 108 Declan RICE Inglaterra 60
#> 4 103 Frenkie DE JONG Países Baj… 68
#> 5 99 Dani OLMO España 54
#> 6 98.5 Marcelo BROZOVIC Croacia 90.5
#> 7 95 Granit XHAKA Suiza 60
#> 8 93 Jamal MUSIALA Alemania 58
#> 9 92.5 JOAO FELIX Portugal 37
#> 10 92 Wataru ENDO Japón 73
#> 11 91 Tyler ADAMS Estados Un… 64
#> 12 90 Thomas MUELLER Alemania 26
#> 13 88 BRUNO FERNANDES Portugal 59.5
#> 14 88 Pau TORRES España 189
#> 15 86 NEYMAR Brasil 63
#> 16 85.5 Cesar AZPILICUETA España 81.5
#> 17 82 Axel WITSEL Bélgica 51
#> 18 82 Yuki SOMA Japón 18
#> 19 81.5 GAVI España 35.5
#> 20 81 Orbelin PINEDA México 40
#> # … with abbreviated variable name ¹ReceptionsMEAN
## Explore data
df.player |>
arrange(desc(ReceptionsMEAN)) |>
head(20) |>
select(ReceptionsMEAN, everything())
#> # A tibble: 20 × 4
#> # Groups: player [20]
#> ReceptionsMEAN player NameTeam OffersToR…¹
#> <dbl> <chr> <chr> <dbl>
#> 1 189 Pau TORRES España 88
#> 2 171 RODRI España 75
#> 3 145 Aymeric LAPORTE España 21
#> 4 120 Nico SCHLOTTERBECK Alemania 38
#> 5 113 Pedri GONZALEZ España 128.
#> 6 103 Joshua KIMMICH Alemania 46
#> 7 93 Niklas SUELE Alemania 43
#> 8 92 Marcos LLORENTE España 51
#> 9 90.5 Marcelo BROZOVIC Croacia 98.5
#> 10 88 Antonio RUEDIGER Alemania 24
#> 11 88 John STONES Inglaterra 48
#> 12 87 Jordi ALBA España 57.5
#> 13 86 Toby ALDERWEIRELD Bélgica 23
#> 14 85 Jan VERTONGHEN Bélgica 42
#> 15 84 Andreas CHRISTENSEN Dinamarca 31
#> 16 81.5 Cesar AZPILICUETA España 85.5
#> 17 81 Benjamin PAVARD Francia 78
#> 18 80.5 Dejan LOVREN Croacia 46.5
#> 19 79.5 THIAGO SILVA Brasil 24.5
#> 20 78 Nicolas OTAMENDI Argentina 30
#> # … with abbreviated variable name ¹OffersToReceiveMEAN
Un control más:
## Filter
df.player |>
filter(player == "Anthony CONTRERAS")
#> # A tibble: 1 × 4
#> # Groups: player [1]
#> player NameTeam OffersToReceiveMEAN Recepti…¹
#> <chr> <chr> <dbl> <dbl>
#> 1 Anthony CONTRERAS Costa Rica 20.5 9.5
#> # … with abbreviated variable name ¹ReceptionsMEAN
Y ya estamos listos para visualizar este análisis de quiénes la piden más y quiénes la reciben más, en el que voy a destacar a las selecciones de Costa Rica, Francia y Argentina:
## Plot
ggplot(df.player,
aes(x = OffersToReceiveMEAN,
y = ReceptionsMEAN)) +
geom_point(color = "black", shape = 4) +
geom_point(data = subset(df.player, NameTeam == "Argentina"),
color = "blue",
shape = 19,
size = 2) +
geom_point(data = subset(df.player, player == "Lionel MESSI"),
color = "blue",
fill = "blue",
shape = 15) +
geom_point(data = subset(df.player, NameTeam == "Francia"),
color = "gold",
shape = 19,
size = 2) +
geom_point(data = subset(df.player, player == "Kylian MBAPPE"),
color = "gold",
fill = "gold",
shape = 15) +
geom_point(data = subset(df.player, NameTeam == "Costa Rica"),
color = "red",
shape = 19,
size = 2) +
geom_point(data = subset(df.player, player == "Joel CAMPBELL"),
color = "red",
fill = "red",
shape = 15) +
geom_smooth(method = "lm", se = FALSE, color = "grey") +
theme_linedraw() +
geom_text_repel(data = subset(
df.player, c(player == "Lionel MESSI" | player == "Joel CAMPBELL" | player == "Kylian MBAPPE")
),
vjust = -11,
hjust = 3,
size = 3,
fontface = "bold",
position = position_jitter(),
aes(label = str_extract_all(player, "[A-Z]{2,}"))) +
theme(legend.position = "none")
#> `geom_smooth()` using formula = 'y ~ x'
¿Los que más la piden son los que más la reciben?
## Compute
cor(df.player$OffersToReceiveMEAN, df.player$ReceptionsMEAN)
#> [1] 0.546408
La correlación es positiva pero no demasiado fuerte: pedirla mucho no asegura nada.
El problema de la visualización de arriba es que los outliers la estiraron mucho. Típico, los outliers nos arruinan las visualizaciones porque causan un molote de observaciones.
Les propongo un caso extremo:
## Plot
plot(starwars$mass, starwars$height, pch = 0, col = "red")
text(1080, 175, "pinche outlier desmadroso:")
Ya que estamos en esto, visualicemos puro outlier:
## Get outliers
outliers.x <- boxplot(df.player$OffersToReceiveMEAN, plot = FALSE)$out
outliers.y <- boxplot(df.player$ReceptionsMEAN, plot = FALSE)$out
## Plot
ggplot(df.player,
aes(x = OffersToReceiveMEAN,
y = ReceptionsMEAN)) +
geom_point(color = "plum1", shape = 4) +
geom_point(
data = subset(df.player,
c(ReceptionsMEAN %in% outliers.y | OffersToReceiveMEAN %in% outliers.x)),
color = "deeppink4",
shape = 19,
size = 1.5) +
geom_smooth(method = "lm", se = FALSE, color = "grey") +
theme_linedraw() +
geom_text_repel(data = subset(df.player,
c(ReceptionsMEAN %in% outliers.y | OffersToReceiveMEAN %in% outliers.x)),
size = 2.5,
fontface = "bold",
position = position_jitter(),
aes(label = player)) +
theme(legend.position = "none")
#> `geom_smooth()` using formula = 'y ~ x'
Para finalizar, veamos a los campeones (aprovechando que ningún outlier es argentino, los voy a remover para que todo se aprecie mucho mejor):
## Remove outliers
df.player.clean <- df.player |>
filter(!c(OffersToReceiveMEAN %in% outliers.x | ReceptionsMEAN %in% outliers.y))
Listo, a graficar:
## Plot
ggplot(df.player.clean,
aes(x = OffersToReceiveMEAN,
y = ReceptionsMEAN)) +
geom_point(color = "skyblue1", shape = 4) +
geom_point(data = subset(df.player.clean, NameTeam == "Argentina"),
color = "navyblue",
shape = 19,
size = 1.5) +
geom_smooth(method = "lm", se = FALSE, color = "grey") +
theme_linedraw() +
geom_text_repel(data = subset(df.player.clean, c(NameTeam == "Argentina")),
size = 3,
fontface = "bold",
position = position_jitter(),
aes(label = player)) +
theme(legend.position = "none")
#> `geom_smooth()` using formula = 'y ~ x'
Y pues uno de la Sele, también sin outliers:
## Plot
ggplot(df.player.clean,
aes(x = OffersToReceiveMEAN,
y = ReceptionsMEAN)) +
geom_point(color = "yellow3", shape = 4) +
geom_point(
data = subset(df.player.clean, c(NameTeam == "Costa Rica")),
color = "red",
shape = 19,
size = 1.5
) +
geom_smooth(method = "lm", se = FALSE, color = "grey") +
theme_linedraw() +
geom_text_repel(
data = subset(df.player.clean, c(NameTeam == "Costa Rica")),
size = 3,
fontface = "bold",
position = position_jitter(),
aes(label = str_extract_all(player, "[A-Z]{2,}"))) +
theme(legend.position = "none")
#> `geom_smooth()` using formula = 'y ~ x'
#> Warning: ggrepel: 1 unlabeled data points (too many
#> overlaps). Consider increasing max.overlaps
Y una comparativa entre Costa Rica y Argentina:
## Plot
ggplot(df.player.clean,
aes(x = OffersToReceiveMEAN,
y = ReceptionsMEAN)) +
geom_point(color = "black", shape = 4) +
geom_point(data = subset(df.player, NameTeam == "Argentina"),
color = "blue",
shape = 19,
size = 2) +
geom_point(data = subset(df.player, player == "Lionel MESSI"),
color = "blue",
fill = "blue",
shape = 15) +
geom_point(data = subset(df.player, NameTeam == "Costa Rica"),
color = "red",
shape = 19,
size = 2) +
geom_point(data = subset(df.player, player == "Joel CAMPBELL"),
color = "red",
fill = "red",
shape = 15) +
geom_smooth(method = "lm", se = FALSE, color = "grey") +
theme_linedraw() +
geom_text_repel(data = subset(
df.player.clean, c(player == "Lionel MESSI" | player == "Joel CAMPBELL")
),
vjust = -11,
hjust = 3,
size = 3,
fontface = "bold",
aes(label = str_extract_all(player, "[A-Z]{2,}"))) +
theme(legend.position = "none")
#> `geom_smooth()` using formula = 'y ~ x'
Es todo, por ahora.