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 THIS!
      IF FALSE, TRY THE FOLLOWING else if
  } else if(IF THIS IS TRUE){
    THEN DO THIS
      IF FALSE, TRY THE FOLLOWING else if
  } else if(IF THIS IS TRUE){
    THEN DO THIS
      IF FALSE, TRY THE FOLLOWING else
  } 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:

## Compare
nrow(df.wc) - nrow(df.player)
#> [1] 170

A ver si es cierto:

df.wc |>
  filter(TimePlayed <= 15) |>
  nrow()
#> [1] 170

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:

## Compare
mean(df.goals$GoalsSUM)
#> [1] 2.65625
median(df.goals$GoalsSUM)
#> [1] 2

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.