Itzulpena: Tidytuesday 2020-03-03

Animar capas de anotaciones con gganimate

16 de marzo de 2020

Herramientas: ggplot2, dplyr, gganimate

Introducción

Hace unos días publiqué un post para el tidytuesday (2020-03-03) con datos de la NHL.

El gráfico muestra el porcentaje de los goles obtenidos por un determinado jugador en toda su carrera con respecto al total de los goles que se metieron en la liga.

Una vez revisado el gráfico, observamos lo siguiente:

  • Los jugadores con un mayot porcentaje de goles sobre el total se retiraron antes del cambio de milenio.
  • El siguiente grupo de jugadores con mejor porcentaje se retiró antes de 2007.
  • Los jugadores comenzaron su carrera a partir de los noventa tienen un porcentaje a sus predecesores.
  • Ovechkin, último jugador en superar los 700 goles, y aún en activo, mejora el porcentaje, aunque sigue por debajo del segundo grupo.

No tenemos que olvidar que el indicador que estamos utilizando no tiene por qué coincidir con el número de goles obtenidos por cada jugador; si nos fijamos en el segundo grupo (jugadores con un porcentaje entre el 7% y el 8%) solo uno de los jugadores obtuvo más de 700 goles.

Para facilitar la comunicación de estas observaciones, planteamos crear una pequeña animación que muestre los distintos grupos de jugadores observados uno a uno, para lo que usaremos el paquete gganimate.

Ésta la primera vez que uso este paquete más allá replicar ejemplos concretos, por lo que he tenido que realizar varios intentos antes de conseguir el resultado deseado. En este post recogeré mis conclusiones principales y la forma en la que he conseguido generar la animación deseada con gganimate.

Sobre gganimate

gganimate extends the grammar of graphics as implemented by ggplot2 to include the description of animation. It does this by providing a range of new grammar classes that can be added to the plot object in order to customise how it should change with time.

Después de varias iteraciones me ha quedado claro que gganimate es una herramienta mejor preparada para la fase EDA que para comunicar las conclusiones obtenidas a partir de ese mismo análisis inicial.

Resumiendo mucho, gganimate saca una serie de fotos de los datos, y crea la animación entre dichas fotos. Para sacar cada una de las fotos, se basa en los datos existentes, usando funciones transition_*(). En concreto, he trabajado con dos de estas funciones:

  • transition_states(): crea las fotos a partir de una variable. En nuestro ejemplo, se trataría de una variable inexistente en el dataset original, ya que las agrupaciones que hemos mencionado en la introducción vienen dadas del análisis de los datos. Por eso, para poder usar este filtro primero tendríamos que añadir una nueva variable computada al conjunto de datos inicial (se npuede hacer fácilmente con las funciones mutate() y case_when()).
  • transition_filter(): crea las fotos a partir de distintos filtros. Finalmente he optado por esta opción, ya que me permite no tener que transformar los datos originales (en cualquier caso, los filtros que vamos a utilizar son los mismos que usaríamos con la función case_when() mencionada en el punto anterior).

Para este ejemplo no vamos a utilizar las funciones más vistosas de la animación (enter_*() y exit_*()), ya que simplemente queremos que las nuevas capas de datos vayan apareciendo y añadiéndose a las anteriores.

Preparar los datos

# Cargar librerías

library(tidyverse)
library(gganimate)

A diferencia del ejemplo anterior, en este ejemplo vamos a utizar geom_segment() para dibujar la duración de la carrera de cada jugador. La estructura de datos que necesitaremos es más simple, ya que no hace falta pivotar los datos para poder dibujar geom_segment(), ya que disponemos de los datos para marcar el inicio y el final de cada línea.

# Obtener los datos

datos_carrera_top_ten_anim <- datos %>% 
  group_by(player) %>% 
  summarise(inicio = as.integer(min(season)),
            final = as.integer(max(season)),
            goals = sum(goals_player),
            career_goals_all = sum(season_goals),
            player_ratio_career = goals / career_goals_all) %>%  
  mutate(estado = case_when(
    as.integer(final) == lubridate::year(Sys.Date()) ~ "En activo",
    TRUE ~ "Retirado"),
    grosor = case_when(
      estado == "Retirado" ~ 1,
      estado == "En activo" ~ 2,
      TRUE ~ 0),
    texto = paste0(player," (",goals,"/",career_goals_all,")")
  ) %>% 
  top_n(10, goals)
datos_carrera_top_ten_anim
## # A tibble: 10 x 9
##    player inicio final goals career_goals_all player_ratio_ca… estado grosor
##    <chr>   <int> <int> <dbl>            <dbl>            <dbl> <chr>   <dbl>
##  1 Alex …   2006  2020   701            10668           0.0657 En ac…      2
##  2 Brett…   1987  2006   741             8621           0.0860 Retir…      1
##  3 Jarom…   1991  2018   766            13222           0.0579 Retir…      1
##  4 Luc R…   1987  2006   668             8621           0.0775 Retir…      1
##  5 Mario…   1985  2006   690             8001           0.0862 Retir…      1
##  6 Mark …   1980  2004   694             9759           0.0711 Retir…      1
##  7 Mike …   1980  1998   708             7432           0.0953 Retir…      1
##  8 Steve…   1984  2006   692             9620           0.0719 Retir…      1
##  9 Teemu…   1993  2014   684            11619           0.0589 Retir…      1
## 10 Wayne…   1980  1999   894             7803           0.115  Retir…      1
## # … with 1 more variable: texto <chr>

Visualización de base

Recreamos el código del post anterior, con las modificaciones pertinentes para poder dibujar los segmentos (en lugar de las líneas). Además, y para entender mejor los problemas que tendremos luego con gganimate, separaremos las capas básicas de las capas con anotaciones post-análisis.

Gráfico base

p <- ggplot(datos_carrera_top_ten_anim, aes(inicio, player_ratio_career, group=player, color=estado)) +
  # dibujamos las líneas
  geom_segment(aes(x = inicio, xend = final, y = player_ratio_career, yend = player_ratio_career, size = fct_rev(as.factor(estado)), 
                linetype=fct_rev(as_factor(estado)))) +
  # dibujamos los corchetes
  geom_point(shape=91, 
             size = 4) +
  geom_point(data = filter(datos_carrera_top_ten_anim, final != lubridate::year(Sys.Date())), aes(x = final), shape=93, 
             size = 4) +
  # Mostramos los nombres de los jugadores, con desplazamientos ad hoc para que no se solapen
  geom_text(data = filter(datos_carrera_top_ten_anim, !player %in% c(subir, bajar)),
            aes(label = texto),
            x = lubridate::year(Sys.Date()) + 1, hjust = "left") +
  geom_text(data = filter(datos_carrera_top_ten_anim, player %in% bajar),
            aes(y = player_ratio_career - 0.0015, label = texto), 
            x = lubridate::year(Sys.Date()) + 1, hjust = "left") +
  geom_text(data = filter(datos_carrera_top_ten_anim, player %in% subir),
            aes(y = player_ratio_career + 0.0015, label = texto), 
            x = lubridate::year(Sys.Date()) + 1, hjust = "left") +
  labs(
    title = "¿Cuál es la aportación de los máximos goleadores?",
    subtitle = "Estamos acostumbrados a ver la lista de los máximos goleadores, pero hace años un número parecido de goles\nen la carrera de un jugador suponía un mayor porcentaje de todos los goles de la liga.\n",
    caption = "\n@neregauzak | #tidytuesday | Data: hockey-reference.com"
  ) +
  # Modificamos la escala de color para usar colores personalizados
  scale_color_manual(values = c("#4f970c","#707173")) +
  # Modificamos la escala de formas para usar corchetes de apertura y cierre con geom_point
  scale_shape_manual(values = c(93,91)) +
  # Modificamos la escala de tamño para dar más anchura a las líneas de jugadores en activo
  scale_size_discrete(range = c(0.2,1.5), breaks = c(1,2)) +
  # Modificamos la escala de tamño para dar un estilo de linea discontinua a las líneas de jugadores en activo
  scale_linetype_manual(values=c("solid","longdash")) +
  # Modificamos los límites de X para poder acomodar los nombres de los jugadores
  scale_x_continuous("", limits = c(min(datos_carrera_top_ten_anim$inicio)-1, max(datos_carrera_top_ten_anim$final)+15),
                     breaks = seq(from = min(datos_carrera_top_ten_anim$inicio), to = max(datos_carrera_top_ten_anim$final), by = 5)
  ) +
  scale_y_continuous("", labels = scales::percent_format()) +
  # Eliminamos las leyendas
  guides(color = "none", size = "none", linetype = "none", shape ="none") +
  #○ Seleccionamos el tema minimal y realizamos un par de modificaciones
  theme_minimal() +
  theme(panel.border = element_blank(), panel.grid.major = element_blank(),
        panel.grid.minor = element_blank(), axis.line = element_line(colour = "black"))
## Warning: Using size for a discrete variable is not advised.
p   

Gráfico con anotaciones

p_anotated <- p +  geom_vline(xintercept = 2020, 
                      linetype = "longdash", color = "#7c4d25", size = 1) +
  geom_label(x = lubridate::year(Sys.Date()), 
                     y = max(datos_carrera_top_ten_anim$player_ratio_career) - 0.01, 
                     hjust = "middle", label ="Última temporada", color ="#7c4d25") +
  # Añadimos anotaciones personalizadas para saber cómo interpretar las líneas
  ## Anotación para un jugador cuya carrera ha terminado
  annotate(geom = "text", x = 2000, y = 0.105, size = 3, color = "#3e3d40", 
           label ="Gretzky jugó entre 1979 y 1999\n y metió 894 goles", 
           lineheight = 0.9) +
  annotate(geom ="curve", x = 1992, y = 0.1075, xend = 1990, yend = 0.1145, 
           curvature = -0.25, color = "#3e3d40") +
  ## Anotación para el jugador que aún está en activo
  annotate(geom = "text", x = 2015, y = 0.08, size = 3, color = "#4f970c", 
           label ="Ovechkin es el único\njugador en activo\nde la lista de los\n10 máximos goleadores", 
           lineheight = 0.9) +
  annotate(geom ="curve", x = 2010, y = 0.08, xend = 2007, yend = 0.0657, 
           curvature = 0.25, color = "#4f970c")

p_anotated

Animación

Como ya se ha mencionado, usaremos la función transition_filter() para crear los estados que nos interesan. A esta función podemos pasarle una serie de filtros, a los que podemos poner nombres (más adelante veremos la utilidad de estos nombres).

En nuestro caso, tenemos que fijarnos en los años en los que hemos detectado los cortes entre los distintos grupos de jugadores, y usarlos para crear los filtros.

Nos interesa que los nuevos datos vayan acumulándose en el gráfico; en principio, podríamos obtener este resultado añadiendo la función shadow_mark() despúes de la función de transición , pero en este caso da error.

También podemos usar el argumento keep = TRUE; el funcionamiento de este argumento no es tan intuitivo como parece a simple vista, recomiendo echar un vistazo en este post explican cómo funciona. Lo que hace keep = TRUE es mantener todos los elementos en el gráfico

Para obtener el efecto deseado, tenemos que “pisar” los elementos gráficos; para el resultado final cambiaremos el color de todos los elementos a blanco, pero de momento vamos a marcar otro color para ver cómo funciona gganimate.

anim_filter <- p_anotated + transition_filter(transition_length = 0.5, 
                      filter_length = 5, 
                      ` ` = final < 1900, 
                      `Jugadores retirados antes de 2000` = final < 2000, 
                      `Jugadores retirados antes de 2007` = final < 2007, 
                      `Jugadores retirados antes de 2020` = final < 2020, 
                      `Todos los jugadores`= final < 2021,
                      wrap = TRUE, keep = TRUE) +
  exit_manual(function(x) dplyr::mutate(x,colour = "blue"))

anim_filter

  • El primer primer fotograma muestra todos los elementos en azul (recordemos que en la versión final se mostrarán todos en blanco, por lo que la percepción será que partimos de un lienzo en blanco).
  • El segundo fotograma muestra dos líneas en gris (y sus corchetes y textos), así como la etiqueta de “última temporada”.
  • Los fotogramas tercero y cuarto muestran suceesivamente otros conjuntos de líneas en gris.
  • El último fotograma muestra la línea del único jugador en activo (verde discontinua).
  • La línea vertical y las anotaciones manuales se muestran en todo momento en azul; dicho de otra forma, si hubieramos seleccionado el color blanco, esos elementos no se llegarían a ver en ningún momento.

¿A qué se debe este comportamiento? ¿Por qué se visualiza uno de los elementos contextuales que hemos usado (la etiqueta de “última temporada”) y no el resto?

Si nos fijamos en cómo están definidos los distintos elementos que hemos usado para crear las anotaciones observamos que:

  • Dos de los elementos están creados con funciones geom; estás funciones están vinculadas a un conjunto de datos y sus variables. Uno de estos elementos (etiqueta “última temporada”) entra correctamente en la animación (es decir, cambia de color en el segundo fotograma, por lo que pasa a ser visible). El otro (línea vertical) no cambia de color (azul), por lo que en la versión final resultaría invisible.
  • Los otros cuatro elementos están creados con funciones annotate, que son independientes de los conjuntos de datos. Ninguno de estos elementos entra correctamente en la animación (queremos que aparezca uno en el segundo fotograma -junto a las primeras líneas- y otro con el último fotograma).

¿Qué diferencia los dos elementos geom, de tal forma que gganimate sólo tiene en cuenta uno de ellos a la hora de generar la animación? Para dar con la respuesta tenemos que fijarnos en los aes obligatorios que se han aplicado a los distintos geoms. geom_vline() es uno de los pocos geoms que no tienen que indicar el canal x obligatoriamente. Tras realizar pruebas con varios geoms, se ve que aquellos que tienen que que indicar x obligatoriamente (o lo heredan de las asignaciones realizadas en ggplot()) entran en las animaciones de gganimate, mientras que resto no.

Por otra parte, ¿qué criterio ha seguido gganimate para decidir en qué fotograma aplicar el cambio de color? No hemos de olvidar que los filtros en juego solo tienen en cuenta la variable final, y que la capa geom_label() carece de dicho dato. De la página de referencia de transition_filter():

If keep = TRUE the rows not matching the conditions of a filter is not removed from the plot after the exit animation

Como hemos usado keep = TRUE y no se puede aplicar ninguno de los filtros a geom_label() (ya que carecemos de un valor para la variable final), gganimate muestra en todo momento la etiqueta (que, en este caso, es el efecto que buscamos). Sin embargo, nos quedan un par de detalles por configurar:

  • Necesitamos que gganimate muestre la línea de referencia junto a la etiqueta Última temporada desde el principio.
  • También queremos que se muestren las anotaciones manuales, y además en distintos fotogramas.

Preparación de los datos para las capas de anotaciones

Como ya hemos dicho, para que gganimate tenga en cuenta unas marcas gráficas, tienen que ser de tipo geom y contener la(s) variable(s) utilizadas a modo de filtros de la animación.

Por lo tanto, vamos a:

  • Sustituir las capas annotate por geom_text() y geom_segment().
  • Crear un dataset que recogerá las variables del dataset original, un par de variables necesarias para los geom_segment() y una variable para poder controlar que filas del nuevo dataset queremos aplicar a cada capa anotación.
    • x. y, xend, yend, label, final, player, estado: solo rellenaremos los datos necesarios, al resto les asignaremos NA. En el caso de la variable player, todas las filas que vamos a crear tendrán asignado NA, ya que aunque no lo necesitemos, gganimate no funcionará correctamente si no detecta la variable en el conjunto de datos.
    • type: vamos a usar esta variable como un metadato que nos permitirá filtrar las filas del nuevo dataset dependiendo del geom con el que estemos trabajando.

El nuevo dataset quedará de la siguiente manera:

##    type    x         y xend   yend
## 1 vline 2020        NA   NA     NA
## 2 label 2020 0.1045713   NA     NA
## 3  text 2000 0.1050000   NA     NA
## 4 curve 1992 0.1075000 1990 0.1145
## 5  text 2015 0.0800000   NA     NA
## 6 curve 2010 0.0800000 2007 0.0657
##                                                                                label
## 1                                                                               <NA>
## 2                                                                   Última temporada
## 3                                 Gretzky jugó entre 1979 y 1999\n y metió 894 goles
## 4                                                                               <NA>
## 5 Ovechkin es el único\njugador en activo\nde la lista de los\n10 máximos goleadores
## 6                                                                               <NA>
##   final player    estado
## 1  1900     NA      <NA>
## 2  1900     NA      <NA>
## 3  1900     NA  Retirado
## 4  1900     NA  Retirado
## 5  2020     NA En activo
## 6  2020     NA En activo
altura_etiqueta <- max(datos_carrera_top_ten_anim$player_ratio_career) - 0.01

type <- c("vline","label","text","curve","text","curve")
x <- c(2020,2020,2000,1992,2015,2010)
y <- c(NA,altura_etiqueta,0.105,0.1075,0.08,0.08)
xend <- c(NA,NA,NA,1990,NA,2007)
yend <- c(NA,NA,NA,0.1145,NA,0.0657)
label<- c(NA,"Última temporada","Gretzky jugó entre 1979 y 1999\n y metió 894 goles",NA,"Ovechkin es el único\njugador en activo\nde la lista de los\n10 máximos goleadores",NA)
final <- c(1900,1900,1900,1900,2020,2020)
player <- c(NA, NA, NA, NA, NA, NA)
estado <- c(NA,NA,"Retirado","Retirado","En activo","En activo")

annotation_data <- data.frame(type = type,
                              x = x,
                              y = y,
                              xend = xend,
                              yend = yend,
                              label = label,
                              final = final,
                              player = player,
                              estado = estado)

Reconfigurar los elementos de las capas con anotaciones

Una vez creado el conjunto de datos, podemos rehacer las capas con las anotaciones. Ahora que tenemos los datos en un data.frame no es necesario crear una capa para cada anotación de texto o línea conectora; podemos mapear los datos a los canales gráficos aes correspondientes:

p_annotated <- p + geom_vline(data = filter(annotation_data, type == "vline"), aes(xintercept = x), 
                       linetype = "longdash", color = "#7c4d25", size = 1) +
  geom_label(data = filter(annotation_data, type == "label"),
                     aes(x = x, y = y, label = label),
                     color ="#7c4d25", hjust = "middle") +
  geom_text(data = filter(annotation_data, type == "text"),
            aes(x = x, y = y, label = label), hjust = "middle", size = 3, lineheight = 0.9) +
  geom_segment(data = filter(annotation_data, type == "curve"),
               aes(x = x, y = y, xend = xend, yend = yend), arrow = arrow(ends = "first"))

Hemos realizado otro pequeño cambio, pasando de conectores curvos a rectos; esto se debe a que el argumento curve de geom_curve() no puede pasarse a través de la función aes(), por lo que solo podemos pasar un único valor a las dos curvas. En este caso tenemos una de signo positivo y otra de signo negativo, por lo que una de las dos no quedaría bien (NOTA: también podríamos haber diferenciado en el dataset las dos curvas para usar el mismo truco, por ejemplo type = [curva_pos|curva_neg]).

Generar la animación

Ahora ya podemos obtener la animación tal y como la queríamos:

anim_filter <- p_annotated + transition_filter(transition_length = 0.5, 
                                      filter_length = 5, 
                                      ` ` = final < 1900, 
                                      `Jugadores retirados antes de 2000` = final < 2000, 
                                      `Jugadores retirados antes de 2007` = final < 2007, 
                                      `Jugadores retirados antes de 2020` = final < 2020, 
                                      `Todos los jugadores`= final < 2021,
                                      wrap = TRUE, keep = TRUE) +
  exit_manual(function(x) dplyr::mutate(x,colour = "white"))

animate(anim_filter, width = 800, height = 600)

Utilizar variables de etiquetas generadas por gganimate

Finalmente, añadiremos un nuevo elemento en la capa de anotaciones que nos permitirá saber qué datos estamos viendo en cada momento. Cada transición de gganimate genera una serie de etiquetas que podemos visualizar en el gráfico:

It can be quite hard to understand an animation without any indication as to what each time point relates to. gganimate solves this by providing a set of variables for each frame, which can be inserted into plot labels using glue syntax.

En este enlace podemos ver las variables de etiqueta que podemos usar con transition_filter().

Estas etiquetas pueden utilizarse en los elementos title, subtitle, caption y tag, y pueden concatenarse a textos fijos. En este ejemplo, ya hemos hecho uso de title, subtitle, y caption y no tenemos intención de modificarlos, por lo que insertaremos las etiquetas en el elemento tag.

Por otra parte, el posicionamiento por defecto de este elemento es arriba a la izquierda, antes del título. A fin de que se integre mejor con los datos, vamos a cambiar la posición de la etiqueta.

anim_filter <- p_annotated + transition_filter(transition_length = 0.5, 
                                      filter_length = 5, 
                                      ` ` = final < 1900, 
                                      `Jugadores retirados antes de 2000` = final < 2000, 
                                      `Jugadores retirados antes de 2007` = final < 2007, 
                                      `Jugadores retirados antes de 2020` = final < 2020, 
                                      `Todos los jugadores`= final < 2021,
                                      wrap = TRUE, keep = TRUE) +
  labs(tag = "{closest_filter}") +
  theme(plot.tag.position = c(0.075,0.1),
        plot.tag = element_text(face = "bold", size = 15, color = "#7c4d25", hjust = 0)) +
  exit_manual(function(x) dplyr::mutate(x,colour = "white"))

animate(anim_filter, width = 800, height = 600)


Más posts