ggplot2 en R
Curso para principiantes

Visualización de datos en R con ggplot2

Aprende a leer un JSON, convertirlo en un data frame y construir gráficos profesionales con ggplot2, paso a paso y con explicaciones claras. Pensado para personas que recién parten en programación.

Todo el curso usa un mismo dataset real y entrañable: 14 años de la vida del gato Pépito 🐱, que tuitea cada vez que entra o sale de casa desde 2011. Datos de verdad, con sus rarezas del mundo real.

~5 horas
12 módulos
R + ggplot2
Nivel básico

Este curso es un complemento de Hazla con Datos. Puedes tomarlo antes, durante o después del curso principal.

🐱

Conoce el dataset: el gato Pépito

Durante todo el curso usaremos un mismo archivo JSON público con cada entrada y salida de Pépito desde noviembre de 2011. No hay que descargar nada: lo leeremos directo desde la web con jsonlite::fromJSON(). Tiene los típicos "problemas reales" que vas a aprender a resolver: fechas en texto, zonas horarias y eventos sueltos.

Ver el repositorio de datos →

Sitio oficial de Pépito: pepitothecat.xyz (incluye una sección para desarrolladores).

⚙️

Prerequisito: Configurar tu entorno

Para seguir los ejemplos necesitas tener R y Positron instalados. Si recién partes, sigue primero la guía Configuración del Entorno: te lleva paso a paso de cero (terminal, R con Rig, Pak, Positron y más).

Ir a Configuración del Entorno →
📊

Parte de Hazla con Datos

Este curso es un recurso complementario de Hazla con Datos, una comunidad enfocada en programación y ciencia de datos en salud. Encuentra más cursos, recursos y herramientas para tu camino profesional.

Visitar hazlacondatos.com →
01

Bienvenida: 14 años de un gato y un montón de datos

Bienvenida

Antes de escribir una sola línea de código, déjame contarte una historia.

En 2011, un ingeniero francés llamado Clément Storck le puso un sensor a la gatera (la puertita por donde entra y sale su gato) de su casa. Cada vez que su gato negro, Pépito, entra o sale, el sensor lo detecta, saca una foto y publica un tuit automático: “Pépito est sorti” (Pépito salió) o “Pépito est rentré” (Pépito volvió). Lleva haciéndolo sin parar desde noviembre de 2011.

El resultado es uno de los datasets más entrañables de internet: casi 14 años de la vida cotidiana de un gato, evento por evento. ¿A qué hora sale? ¿Cuánto tiempo pasa afuera? ¿Sale más en verano? ¿Cambió su rutina con los años? Todo eso está en los datos, esperando a que alguien lo grafique.

Ese alguien vas a ser tú. Y la herramienta va a ser ggplot2.

Vamos a tomar un archivo de datos crudo de la web y, paso a paso, convertirlo en gráficos claros y bonitos que cuenten la historia de Pépito.

¿Qué es ggplot2?

ggplot2 es el paquete de R para hacer gráficos. Es, probablemente, la razón número uno por la que mucha gente que trabaja con datos elige R. Con él puedes hacer desde un gráfico de barras rápido hasta una visualización lista para publicar en un informe o una revista científica.

Lo que lo hace especial es que no aprendes 50 funciones distintas (una para barras, otra para líneas, otra para histogramas…). En vez de eso, aprendes un único sistema —una “gramática”— y con ella construyes cualquier gráfico combinando piezas. Por eso se llama ggplot: la gg viene de Grammar of Graphics (la gramática de los gráficos).

💡

¿Gramática? ¿Como la del lenguaje?

Exactamente. Así como en español combinas sujeto + verbo + complemento para formar cualquier frase, en ggplot2 combinas datos + una forma geométrica + unas reglas visuales para formar cualquier gráfico. Una vez que entiendes las piezas, las mezclas como quieras. Eso lo veremos en detalle en el módulo 5.

Lo que vas a construir

Esta es la gráfica final del curso. Resume 14 años en un solo cuadro: cada celda es un mes, el color indica cuántos minutos —en mediana— pasaba Pépito fuera de casa.

💡

El gráfico estrella

Un mapa de calor (heatmap) de año × mes, donde el color va de morado oscuro (poco rato fuera) a amarillo (mucho rato fuera), con el número escrito dentro de cada celda. Se ve, por ejemplo, que en julio de 2015 Pépito batió su récord: una mediana de 252 minutos fuera de casa. ¡Más de 4 horas de aventura diaria!

No te preocupes si ahora suena complicado. Cuando lleguemos al módulo 11 lo vas a construir tú mismo, capa por capa, y vas a entender cada línea.

Mapa de calor de la mediana de minutos que Pépito pasa fuera, por año y mes, entre 2011 y 2024
Resultado esperado. El destino del curso. Cada celda es un mes; el color, cuántos minutos (mediana) pasó Pépito fuera. Lo construirás tú en el módulo 11.

Pero no vamos a empezar por ahí. Vamos a empezar por lo básico (barras simples) y a subir de a poco. En el camino harás muchos gráficos distintos: barras, líneas, histogramas, densidades, cajas (boxplots), líneas de tendencia y, finalmente, mapas de calor.

El camino completo

Hacer una visualización casi nunca es “escribir el gráfico y listo”. Hay un flujo de trabajo que se repite en todos los proyectos de datos, y este curso lo recorre entero:

💡

El flujo que vamos a seguir

  1. Traer los datos a R (en nuestro caso, leer un archivo JSON desde la web).
  2. Entender qué tenemos: cuántas filas, qué columnas, de qué tipo.
  3. Preparar los datos: arreglar las fechas, crear columnas nuevas (la hora, el día, el año…).
  4. Graficar: empezar simple y agregar capas hasta que el gráfico diga lo que queremos.
  5. Pulir y compartir: títulos, colores, temas y guardar la imagen.

Los módulos 3 y 4 cubren traer y preparar los datos. Del 5 al 11 graficamos. El 12 es para pulir y exportar. No te saltes los módulos 3 y 4: el 80% del trabajo real de visualización es, en realidad, preparar bien los datos. Un gráfico bonito sobre datos mal preparados es un gráfico equivocado.

Qué vas a saber al terminar

Al final del curso vas a poder, sin pánico:

  • Leer un archivo JSON desde una URL y convertirlo en una tabla (data frame) de R.
  • Transformar fechas que vienen como texto ("Sun Nov 13 10:47:15 +0000 2011") en fechas reales con las que se puede calcular.
  • Crear columnas nuevas: la hora del día, el día de la semana, el año, la estación.
  • Entender la gramática de ggplot2 y construir un gráfico capa por capa.
  • Elegir el tipo de gráfico correcto según la pregunta: barras, líneas, histogramas, boxplots, mapas de calor.
  • Cambiar colores, escalas, títulos y temas, y guardar tu gráfico como imagen para un informe.
🤔

Detente y piensa

Antes de avanzar, piensa: si tuvieras los datos de Pépito frente a ti, ¿qué le preguntarías? ¿A qué hora sale más? ¿Sale más los fines de semana? ¿Pasa más rato fuera en verano? Anota una o dos preguntas. A lo largo del curso vas a poder responderlas tú mismo con un gráfico.

Cómo está organizado el curso

BloqueMódulosQué aprendes
Empezar1 – 2La idea de ggplot2 y dejar el entorno listo.
De JSON a datos3 – 4Traer el JSON a R y preparar las fechas y columnas.
ggplot2 desde cero5 – 9La gramática, barras, líneas, distribuciones y boxplots.
Análisis y cierre10 – 12Tendencias, el mapa de calor final y cómo pulir y exportar.

Cada módulo tiene explicación paso a paso, código comentado línea por línea, errores comunes (los que de verdad te van a pasar) y un ejercicio con solución colapsable. Lee, prueba el código en tu propio R, equivócate un par de veces, lee la sección de errores y vuelve al ejercicio. Así se aprende de verdad.

La regla de oro para aprender a programar

No copies y pegues sin más. Escribe el código a mano, ejecútalo, y cuando algo falle (va a fallar, a todos nos pasa), lee el mensaje de error con calma. Programar es, en gran parte, aprender a leer errores. En este curso te vamos a mostrar los errores típicos a propósito para que cuando te aparezcan ya sepas qué hacer.

Una nota sobre el pipe |>

En todo el curso vas a ver mucho este símbolo: |>. Se llama pipe nativo (tubería) y es una de las cosas más útiles de R moderno. Por ahora solo quédate con la idea: sirve para encadenar pasos, como decir “toma esto, y luego hazle esto, y luego esto otro”. Lo explicaremos cuando lo usemos por primera vez. Es parte de R desde la versión 4.1 (2021) y no necesita ningún paquete extra.

Listo para empezar

En el próximo módulo dejamos el entorno listo: instalamos los paquetes que vamos a usar (jsonlite, dplyr, lubridate, ggplot2) y aprendemos la diferencia entre instalar y cargar un paquete, que es la confusión clásica al partir.

🧪

Ejercicio 1 — Observa el gato

No hay código todavía. Solo dos cosas:

  1. Entra al sitio oficial de Pépito, pepitothecat.xyz (o busca PepitoTheCat en redes), y mira algunos de sus posts. Fíjate en el formato: cada uno dice si entró o salió, con una hora. El sitio incluso tiene una sección para desarrolladores.
  2. Anota tres preguntas que te gustaría responder con estos datos. Por ejemplo: “¿A qué hora sale más?” o “¿Pasa más rato fuera los fines de semana?”.

Guarda esas tres preguntas. En el módulo 11 vas a poder responderlas mirando tus propios gráficos.

Ver ejemplo de preguntas

Ejemplo de tres buenas preguntas:

  1. ¿En qué franja horaria del día Pépito sale más? (la responderemos con un gráfico de barras en el módulo 6).
  2. ¿Cuánto tiempo pasa normalmente fuera de casa? (un histograma, módulo 8).
  3. ¿Ha cambiado su rutina a lo largo de los años? (una línea de tendencia y un mapa de calor, módulos 10 y 11).

Lo bonito de tener un dataset real y largo es que casi cualquier pregunta razonable tiene respuesta. La habilidad que vas a desarrollar es traducir una pregunta en un gráfico.

02

Preparar el entorno: R, Positron y los paquetes

El objetivo del módulo

Antes de graficar necesitamos dos cosas: R funcionando y los paquetes del curso instalados. En este módulo dejamos todo listo y, de paso, aclaramos una confusión clásica de quien recién parte: la diferencia entre instalar y cargar un paquete.

⚠️

Prerequisito: configura tu entorno primero

Este curso asume que ya tienes R y Positron (el editor que recomendamos) instalados y funcionando. Si todavía no, detente aquí y sigue primero esta guía paso a paso, hecha para principiantes absolutos:

👉 Configuración del Entorno

Te lleva de cero: terminal, instalar R con Rig, pak, Positron y todo lo necesario. Cuando termines, vuelve aquí.

Instalar vs. cargar: la confusión número uno

Esto confunde a casi todo el mundo al empezar, así que vamos con calma usando una analogía:

  • Instalar un paquete es como comprar un libro y ponerlo en tu estante. Lo haces una sola vez. El libro se queda en tu computador para siempre (hasta que lo actualices o borres).
  • Cargar un paquete es como bajar el libro del estante y abrirlo sobre la mesa. Lo haces cada vez que abres R y vas a usar ese paquete.

Si instalas pero no cargas, R te dirá “no encuentro esa función”. Si intentas cargar algo que no instalaste, R te dirá “no existe ese paquete”. Son dos pasos distintos.

💡

En código

  • Instalar (una vez): pak::pak("ggplot2")
  • Cargar (cada sesión): library(ggplot2)

Paso 1 — Instalar los paquetes del curso

Vamos a usar cuatro paquetes principales. Los instalamos todos de una vez. Abre R (o Positron) y ejecuta:

# Instalar (esto se hace UNA sola vez en tu computador)
pak::pak(c("jsonlite", "lubridate", "dplyr", "ggplot2"))

¿Qué hace cada uno?

  • jsonlite — lee archivos JSON (el formato en que vienen los datos de Pépito) y los convierte en tablas de R.
  • lubridate — trabaja con fechas y horas sin volverse loco. Lo necesitamos porque las fechas de Pépito vienen como texto.
  • dplyr — manipula tablas: filtrar filas, crear columnas, agrupar, resumir. Es la navaja suiza de los datos en R.
  • ggplot2 — la estrella del curso: hace los gráficos.

Atajo: ¿ya tienes `tidyverse`?

ggplot2, dplyr y lubridate forman parte del tidyverse, una colección de paquetes que trabajan muy bien juntos. Si prefieres, puedes instalar todo el conjunto de una vez con:

pak::pak("tidyverse")

Así tendrás esos tres (y muchos más, como tidyr o readr) sin instalarlos por separado. Desde tidyverse 2.0.0 (2023), lubridate es parte del core, así que library(tidyverse) carga de una vez ggplot2, dplyr y lubridate. Ojo: jsonlite no viene dentro del tidyverse, así que si instalas el conjunto, agrégalo aparte:

pak::pak(c("tidyverse", "jsonlite"))

En este curso cargamos cada paquete por separado (library(ggplot2), library(dplyr)…) para que veas exactamente de qué paquete viene cada función. Si usas library(tidyverse), esos tres ya quedan cargados y solo te faltaría library(jsonlite).

💡

¿Por qué `pak`?

Quizás en otros tutoriales viste otra forma de instalar paquetes. En este curso usamos siempre pak, porque es más moderno: es más rápido, te explica mejor los errores y resuelve solo las dependencias del sistema. La instalación de pak ya viene cubierta en la guía de Configuración del Entorno que hiciste como prerequisito, así que deberías tenerlo listo.

Paso 2 — Cargar los paquetes

Cada vez que empieces una sesión de R para trabajar en el curso, carga los paquetes al inicio de tu script con library():

# Cargar (esto va al principio de tu script, CADA sesión)
library(jsonlite)
library(lubridate)
library(dplyr)
library(ggplot2)

Pon los `library()` siempre arriba

La costumbre profesional es poner todos los library() juntos en las primeras líneas de tu script. Así, cualquiera que lo abra (incluido tú dentro de seis meses) ve de inmediato qué paquetes necesita. No los escondas en medio del código.

Paso 3 — Comprobar que todo funciona

Hagamos una prueba mínima para confirmar que los paquetes se cargaron bien. Este código dibuja un gráfico de ejemplo con datos que ya vienen dentro de R:

library(ggplot2)
ggplot(mtcars, aes(x = wt, y = mpg)) +
geom_point()

Si te aparece una ventana (o un panel) con una nube de puntos, ¡felicitaciones! ggplot2 está funcionando. No importa qué significa el gráfico todavía; solo queríamos confirmar que el motor enciende.

⚠️

Error típico: `could not find function 'ggplot'`

Causa: instalaste el paquete pero olvidaste cargarlo con library(ggplot2) en esta sesión. Recuerda la analogía: el libro está en el estante, pero no lo abriste sobre la mesa.

Solución: ejecuta library(ggplot2) y vuelve a correr el código.

⚠️

Error típico: `there is no package called 'ggplot2'`

Causa: el paquete no está instalado (o se instaló a medias).

Solución: ejecuta pak::pak("ggplot2") y espera a que termine sin errores. Luego library(ggplot2).

Un par de tips antes de seguir

Reinicia R cuando algo se ponga raro

Si llevas un rato y empiezan a pasar cosas extrañas (resultados que no calzan, errores sin sentido), reinicia la sesión de R: en Positron, Session → Restart R. Esto borra todas las variables y te deja “en limpio”. Sorprende cuántos problemas se arreglan así.

Crea un proyecto para el curso

Crea una carpeta nueva (por ejemplo, curso-ggplot2) y ábrela en Positron con File → Open Folder. Guarda ahí tu script .R. Tener un proyecto ordenado desde el principio te va a ahorrar dolores de cabeza con las rutas más adelante.

Ejercicio

🧪

Ejercicio 2 — Deja tu entorno listo

  1. Instala los cuatro paquetes del curso con un solo pak::pak(...).
  2. Crea un script nuevo llamado pepito.R y escribe en las primeras líneas los cuatro library().
  3. Ejecuta el gráfico de prueba con mtcars.
  4. Confirma que ves la nube de puntos.

Si todo funciona, estás listo para traer los datos de Pépito en el próximo módulo.

Ver solución
# 1. Instalar (una sola vez)
pak::pak(c("jsonlite", "lubridate", "dplyr", "ggplot2"))
# 2. Al inicio de pepito.R, cargar los paquetes
library(jsonlite)
library(lubridate)
library(dplyr)
library(ggplot2)
# 3. Gráfico de prueba
ggplot(mtcars, aes(x = wt, y = mpg)) +
geom_point()

Si ves los puntos, tu entorno está perfecto. Si no, vuelve a leer los dos errores típicos de más arriba: el 95% de las veces es que falta library() o que el pak::pak() no terminó bien.

Nota: mtcars es un conjunto de datos de ejemplo que viene dentro de R (datos de autos). Lo usamos solo para probar; a partir del próximo módulo trabajamos con los datos de Pépito.

03

De la web a R: leer un JSON

El objetivo del módulo

Vamos a traer los datos de Pépito desde la web hasta una variable en R que podamos mirar y manipular. Los datos están en un archivo JSON público, así que primero entendamos qué es eso.

¿Qué es un JSON?

JSON (se lee “yeison”) es un formato de texto muy común para guardar e intercambiar datos, sobre todo en internet. Casi todas las APIs (los “enchufes” por los que las aplicaciones se pasan datos) hablan en JSON.

Un JSON se ve así:

[
{
"full_text": "Pepito est sorti (12:47:41)",
"way": "out",
"created_at": "Sun Nov 13 10:47:15 +0000 2011",
"media": ""
},
{
"full_text": "Pepito est rentré (9:02:53)",
"way": "in",
"created_at": "Mon Nov 14 07:02:25 +0000 2011",
"media": ""
}
]

Léelo con calma, porque es muy intuitivo:

  • Los corchetes [ ] envuelven una lista de cosas. Aquí, una lista de eventos.
  • Las llaves { } envuelven un evento, con sus datos.
  • Dentro, cada dato es un par "nombre": valor. Por ejemplo, "way": "out" significa que ese campo se llama way y su valor es "out".

Cada { } de la lista es un evento de Pépito. Y hay casi 14 años de ellos.

💡

Los campos de los datos de Pépito

  • full_text — el texto del tuit, en francés. "sorti" = salió, "rentré" = volvió.
  • way — la dirección del movimiento: "out" (salió) o "in" (entró). Este campo es oro: con él sabremos cuándo sale y cuándo vuelve.
  • created_at — la fecha y hora del evento, como texto, en formato de Twitter y en hora UTC.
  • media — la URL de la foto del evento. En los primeros años suele venir vacío (la cámara llegó después), pero en los años más recientes trae la imagen en muchos eventos. No la usaremos en este curso.

Leer el JSON con una sola línea

La función fromJSON() del paquete jsonlite hace la magia: le pasas la dirección (URL) del archivo y te devuelve una tabla de R (un data frame) lista para usar.

library(jsonlite)
url <- "https://raw.githubusercontent.com/clement87/Pepito-data/refs/heads/main/tweets.json"
pepito_df <- fromJSON(url)

Línea por línea:

  1. library(jsonlite) — carga el paquete que sabe leer JSON.
  2. url <- "https://..." — guardamos la dirección del archivo en una variable llamada url. La flechita <- significa “asigna a la izquierda lo de la derecha”. Guardar la URL en una variable hace el código más limpio.
  3. pepito_df <- fromJSON(url) — leemos el archivo de esa URL y guardamos el resultado en una variable llamada pepito_df (df por data frame, que es como R llama a las tablas).
💡

¿Qué es un data frame?

Un data frame es simplemente una tabla: tiene filas y columnas, como una hoja de Excel. Cada fila es un evento de Pépito; cada columna es un dato (full_text, way, created_at, media). Es la estructura más importante de R para trabajar con datos, y ggplot2 siempre grafica a partir de un data frame.

Mirar qué llegó

Regla de oro: después de leer datos, siempre míralos antes de hacer nada más. Hay tres funciones que usarás siempre para esto:

# Las primeras 6 filas
head(pepito_df)
# El número de filas y columnas
dim(pepito_df)
# Un resumen de columnas, tipos y primeros valores (necesita dplyr)
library(dplyr)
glimpse(pepito_df)

glimpse() es la más útil. Te muestra algo así:

Rows: 80,000+
Columns: 4
$ full_text <chr> "Pepito est sorti (12:47:41)", "Pepito est sorti...
$ way <chr> "out", "out", "out", "out", "in", "out", ...
$ created_at <chr> "Sun Nov 13 10:47:15 +0000 2011", "Sun Nov 13 ...
$ media <chr> "", "", "", "", "", ...

Léelo así: hay muchísimas filas (son años de datos) y 4 columnas, todas de tipo <chr> (texto, de character). Fíjate en un detalle clave: created_at es texto, no una fecha de verdad. R todavía no sabe que "Sun Nov 13 10:47:15 +0000 2011" es una fecha; para él es solo una palabra larga. Eso lo arreglamos en el módulo 4.

🤔

Detente y piensa

¿Por qué importa que created_at sea texto y no fecha? Porque con texto no puedes calcular. No puedes preguntar “¿cuántos minutos pasaron entre que salió y volvió?” si R cree que las fechas son simples palabras. Convertir ese texto en fecha real es el paso que desbloquea todo el análisis.

Contar entradas y salidas

Hagamos nuestra primera pregunta a los datos. ¿Cuántas veces salió y cuántas entró? La función count() de dplyr cuenta cuántas filas hay de cada valor:

library(dplyr)
pepito_df |> count(way)

Aquí aparece por primera vez el pipe |>. Léelo como la palabra “luego”: “toma pepito_df, luego cuéntalo por way. El resultado es algo como:

way n
<chr> <int>
1 in 40000
2 out 40000

Tiene sentido: para volver a casa, primero hay que salir, así que las entradas y salidas son más o menos iguales.

💡

El pipe `|>` en una frase

x |> f() es lo mismo que f(x). El pipe toma lo que está a su izquierda y lo mete como primer argumento de la función de la derecha. Su gracia es que permite encadenar pasos de forma que se lean de arriba a abajo, como una receta. Lo usaremos sin parar.

Errores comunes al leer un JSON

⚠️

Error 1: `could not find function 'fromJSON'`

Causa: no cargaste jsonlite.

Solución: agrega library(jsonlite) antes de llamar a fromJSON().

⚠️

Error 2: `Could not resolve host` o el código se queda pegado

Causa: fromJSON(url) descarga el archivo desde internet. Si no tienes conexión (o estás detrás de un firewall corporativo muy estricto), falla.

Solución: confirma que tienes internet. Si la red de tu trabajo bloquea descargas, prueba desde otra red. El archivo es público, así que cualquier navegador puede abrir la URL para comprobar que existe.

⚠️

Error 3: escribir mal la URL

Una URL larga es fácil de copiar a medias. Si te falta un pedazo, R no encuentra el archivo. Solución: copia y pega la URL completa, sin espacios ni saltos de línea en medio.

Lee una vez, trabaja muchas

Descargar el JSON toma unos segundos. No pongas fromJSON(url) dentro de cada gráfico. Léelo una sola vez al principio de tu script, guárdalo en pepito_df, y de ahí en adelante trabaja siempre con esa variable. Tu código será mucho más rápido.

Ejercicio

🧪

Ejercicio 3 — Trae y explora los datos

  1. Carga jsonlite y dplyr.
  2. Lee el JSON de Pépito en una variable pepito_df.
  3. Usa glimpse(pepito_df) y responde: ¿cuántas columnas hay? ¿de qué tipo es created_at?
  4. Usa count(way) y mira cuántas salidas y entradas hay.
  5. Extra: ejecuta head(pepito_df$full_text) para ver los textos. ¿En qué idioma están?
Ver solución
library(jsonlite)
library(dplyr)
url <- "https://raw.githubusercontent.com/clement87/Pepito-data/refs/heads/main/tweets.json"
pepito_df <- fromJSON(url)
glimpse(pepito_df)
pepito_df |> count(way)
head(pepito_df$full_text)

Respuestas:

  • Hay 4 columnas (full_text, way, created_at, media), todas de tipo texto (<chr>).
  • created_at es texto, no fecha. Ese es justo el problema que resolveremos en el módulo 4.
  • Los textos están en francés ("sorti", "rentré"), porque Pépito vive en Francia. No necesitamos entender francés: nos basta con la columna way (in/out).

El símbolo $ (en pepito_df$full_text) sirve para sacar una columna específica de la tabla. Lo verás mucho: tabla$columna.

04

Fechas y columnas nuevas con lubridate

El objetivo del módulo

Tenemos los datos, pero la columna de fecha (created_at) es texto, y con texto no se puede calcular ni graficar bien. En este módulo la convertimos en una fecha-hora de verdad y, a partir de ella, creamos columnas nuevas que vamos a usar en casi todos los gráficos: la hora del día, la fecha y el día de la semana.

Este es el módulo más “técnico” de la parte de datos, pero también el más importante. Tómalo con calma.

El problema: una fecha que es texto

Recordemos cómo se ve created_at:

"Sun Nov 13 10:47:15 +0000 2011"

Para un humano es claro: domingo 13 de noviembre de 2011, a las 10:47:15, en hora UTC. Pero para R es una palabra larga sin significado. Si intentas restar dos de estas “palabras” para ver cuánto tiempo pasó, R se queja. Necesitamos traducir ese texto al idioma de fechas de R.

Convertir texto en fecha con parse_date_time()

La herramienta es parse_date_time(), del paquete lubridate. Le decimos en qué orden vienen las piezas de la fecha y él hace la conversión:

library(lubridate)
pepito_df$datetime <- parse_date_time(
pepito_df$created_at,
orders = "a b d H:M:S z Y",
tz = "UTC",
locale = "C"
)

Vamos por partes, porque cada argumento importa:

  • pepito_df$created_at — la columna de texto que queremos convertir.
  • orders = "a b d H:M:S z Y" — el “molde” que describe el orden de las piezas en el texto. Cada letra es un pedazo:
    • a = nombre del día abreviado (Sun)
    • b = nombre del mes abreviado (Nov)
    • d = día del mes (13)
    • H:M:S = hora:minuto:segundo (10:47:15)
    • z = la zona horaria (+0000)
    • Y = año con 4 dígitos (2011)
  • tz = "UTC" — le decimos que estas horas están en UTC (la hora de referencia mundial), porque así las guarda Twitter.
  • locale = "C" — ¡clave! Le decimos que los nombres del día y del mes están en inglés (Sun, Nov). Sin esto, si tu computador está en español, R buscaría "Dom" y "Nov" en español y fallaría.

El resultado es una columna nueva, datetime, que R sí entiende como fecha-hora.

⚠️

El error más común aquí: ¡todo sale NA!

Si después de convertir ves que la columna datetime está llena de NA (valores vacíos), casi siempre es por una de estas dos razones:

  1. Olvidaste locale = "C" y tu sistema está en español: R no reconoce "Sun" ni "Nov".
  2. El orders no calza con el formato real del texto.

Solución: revisa un valor crudo con pepito_df$created_at[1] y compáralo letra por letra con tu orders. El 99% de los problemas de fechas son un orders o un locale mal puestos.

El detalle de la zona horaria: pasar a hora de Francia

Los datos están en UTC, pero Pépito vive en Francia. Si queremos saber “¿a qué hora sale Pépito?”, nos interesa la hora local francesa, no la UTC. En verano Francia va 2 horas adelante de UTC; en invierno, 1 hora. with_tz() hace esa conversión automáticamente, respetando incluso el horario de verano:

pepito_df$datetime_fr <- with_tz(pepito_df$datetime, tzone = "Europe/Paris")

Creamos una columna nueva, datetime_fr, que es el mismo instante pero expresado en hora de París.

💡

`with_tz()` vs `force_tz()` — no las confundas

  • with_tz() = “muéstrame este mismo instante, pero en otra zona horaria”. El momento real no cambia, solo cómo lo leemos. Es la que queremos.
  • force_tz() = “cambia la etiqueta de zona sin convertir la hora”. Esto cambia el instante real. Úsala solo si los datos vinieron con la zona equivocada de origen.

Para Pépito usamos with_tz(): las horas están bien en UTC, solo queremos verlas en hora francesa.

Crear columnas útiles: fecha, hora y día de la semana

Ahora que tenemos una fecha-hora real (datetime_fr), podemos extraer pedazos de ella. Cada uno será una columna nueva que usaremos para graficar:

# Solo la fecha (sin la hora): 2011-11-13
pepito_df$date <- as_date(pepito_df$datetime_fr)
# Solo la hora del día como número 0–23
pepito_df$hour <- hour(pepito_df$datetime_fr)
# El día de la semana, con etiqueta y empezando en lunes
pepito_df$weekday <- wday(pepito_df$datetime_fr, label = TRUE, week_start = 1)

Qué hace cada función de lubridate:

  • as_date() — se queda solo con el día (sirve para gráficos por fecha).
  • hour() — extrae la hora como número entero de 0 a 23 (sirve para “¿a qué hora sale?”).
  • wday(..., label = TRUE, week_start = 1) — el día de la semana. label = TRUE lo devuelve como nombre (lun, mar…) en vez de número; week_start = 1 hace que la semana empiece en lunes (en R, por defecto, empieza en domingo).

Revisar el resultado

Como siempre después de transformar: mira lo que quedó.

library(dplyr)
glimpse(pepito_df)

Ahora deberías ver las columnas nuevas con los tipos correctos:

$ datetime <dttm> 2011-11-13 10:47:15, ...
$ datetime_fr <dttm> 2011-11-13 11:47:15, ... (¡una hora más: invierno en Francia!)
$ date <date> 2011-11-13, ...
$ hour <int> 11, ...
$ weekday <ord> dom, dom, dom, ...

<dttm> significa fecha-hora; <date> fecha; <int> número entero; <ord> factor ordenado (los días de la semana tienen orden). Si ves estos tipos, lo lograste: los datos ya están listos para graficar.

Versión con mutate() (más ordenada)

Ir creando columnas con pepito_df$x <- ... funciona perfecto. Pero a medida que avances verás esta forma encadenada con mutate() de dplyr, que hace lo mismo en un solo bloque:

pepito_df <- pepito_df |>
mutate(
datetime = parse_date_time(created_at, "a b d H:M:S z Y", tz = "UTC", locale = "C"),
datetime_fr = with_tz(datetime, "Europe/Paris"),
date = as_date(datetime_fr),
hour = hour(datetime_fr),
weekday = wday(datetime_fr, label = TRUE, week_start = 1)
)

Las dos formas dan el mismo resultado. Usa la que te resulte más clara; mutate() se vuelve más cómoda cuando creas muchas columnas.

Dos estilos, un mismo resultado

No hay una forma “correcta”: el estilo pepito_df$x <- ... es más explícito al empezar, y mutate() es más compacto y encadenable. En el curso verás ambos; quédate con el que leas con más comodidad.

Errores comunes con fechas

⚠️

Error: las horas se ven 1–2 horas corridas

Si las horas no calzan con lo que esperas, probablemente mezclaste datetime (UTC) con datetime_fr (París) en algún gráfico. Sé consistente: para todo lo que sea “hora local de Pépito”, usa siempre datetime_fr y las columnas derivadas de ella (hour, date, weekday).

⚠️

Error: `hour` te sale como texto y no como número

Si extraes la hora desde la columna de texto original en vez de desde datetime_fr, no funcionará. Asegúrate de que hour() reciba la columna ya convertida (datetime_fr), no created_at.

🤔

Detente y piensa

Fíjate en algo bonito: con with_tz(), el primer evento (13 nov 2011) suma +1 hora (invierno), pero un evento de julio sumaría +2 horas (verano). lubridate conoce las reglas del horario de verano de cada país y las aplica solo. Hacer esto a mano sería una pesadilla; por eso usamos un paquete de fechas en vez de improvisar.

Ejercicio

🧪

Ejercicio 4 — Prepara las columnas de fecha

Partiendo del pepito_df que leíste en el módulo 3:

  1. Crea la columna datetime convirtiendo created_at con parse_date_time() (¡no olvides locale = "C"!).
  2. Crea datetime_fr pasando a hora de París con with_tz().
  3. Crea hour, date y weekday.
  4. Ejecuta glimpse(pepito_df) y confirma que hour es <int> y date es <date>.
  5. Extra: ejecuta pepito_df |> count(weekday). ¿Algún día de la semana se ve más activo?
Ver solución
library(lubridate)
library(dplyr)
pepito_df$datetime <- parse_date_time(pepito_df$created_at,
orders = "a b d H:M:S z Y",
tz = "UTC", locale = "C")
pepito_df$datetime_fr <- with_tz(pepito_df$datetime, tzone = "Europe/Paris")
pepito_df$date <- as_date(pepito_df$datetime_fr)
pepito_df$hour <- hour(pepito_df$datetime_fr)
pepito_df$weekday <- wday(pepito_df$datetime_fr, label = TRUE, week_start = 1)
glimpse(pepito_df)
pepito_df |> count(weekday)

Si glimpse() muestra hour <int> y date <date>, ¡perfecto! Los datos están listos.

Sobre el extra: los conteos por día suelen ser bastante parejos (un gato sale más o menos todos los días), pero pequeñas diferencias pueden aparecer. Lo interesante no es cuántas veces sale cada día, sino a qué hora y cuánto rato — eso lo exploraremos con gráficos en los próximos módulos.

05

La gramática de los gráficos: ggplot2 desde cero

El objetivo del módulo

Este es el módulo más importante de todo el curso. Aquí entenderás la idea central de ggplot2: la gramática de los gráficos. Una vez que la captes, no tendrás que memorizar gráficos: los vas a construir. Todos los demás módulos son variaciones de lo que aprendes aquí.

La idea: un gráfico se arma por capas

En ggplot2, un gráfico no se hace de una. Se construye sumando capas, como apilar transparencias una sobre otra. Cada capa aporta algo:

💡

Las piezas de todo gráfico ggplot2

  1. Los datos (data): la tabla de donde sale todo. Siempre un data frame.
  2. El mapeo estético (aes): qué columna va en el eje X, cuál en el eje Y, cuál define el color, etc. Es “conectar columnas con propiedades visuales”.
  3. La geometría (geom_*): la forma con la que se dibujan los datos. ¿Barras? ¿Puntos? ¿Líneas? Cada forma es un geom_.
  4. Las escalas (scale_*): cómo se traducen los datos a lo visible (qué colores, qué marcas en los ejes…). Opcional.
  5. Las etiquetas (labs): título, nombres de ejes, leyenda. Opcional pero recomendado.
  6. El tema (theme_*): la apariencia general (fondo, tipografía, rejilla). Opcional.

Las tres primeras (datos, aes, geom) son obligatorias: sin ellas no hay gráfico. Las otras tres son para mejorar y pulir.

El esqueleto mínimo

Todo gráfico de ggplot2 sigue esta estructura. Apréndela de memoria, es el molde de todo:

ggplot(datos, aes(x = columna1, y = columna2)) +
geom_algo()

Léelo así: “Crea un gráfico con estos datos, conectando estas columnas a los ejes (aes), y dibújalos con esta forma (geom).”

Fíjate en el + al final de la primera línea: es el “pegamento” que suma capas. Cada nueva capa se agrega con un +.

⚠️

El error clásico número uno: el `+` va al FINAL de la línea

El + que une capas debe ir al final de cada línea, nunca al principio de la siguiente. Esto está bien:

ggplot(pepito_df, aes(x = way)) +
geom_bar()

Esto está mal y R te dará un resultado a medias o un error:

ggplot(pepito_df, aes(x = way))
+ geom_bar()

¿Por qué? Porque al ver una línea que ya está “completa”, R cree que terminaste, y la línea siguiente que empieza con + la lee como algo aparte. Regla: el + siempre cierra la línea de arriba.

Tu primer gráfico, de verdad

Hagamos el gráfico más simple posible con los datos de Pépito: contar cuántas veces salió (out) y cuántas entró (in).

library(ggplot2)
ggplot(pepito_df, aes(x = way)) +
geom_bar()

¡Eso es todo! Obtienes dos barras: una para in y otra para out. Desarmemos qué pasó:

  1. ggplot(pepito_df, ...) — empieza un gráfico usando la tabla pepito_df.
  2. aes(x = way) — pon la columna way en el eje X. No pusimos y porque…
  3. geom_bar() — …las barras cuentan solas cuántas filas hay de cada valor. El eje Y (la altura) lo calcula ggplot2 automáticamente.
💡

¿Por qué `geom_bar()` no necesita `y`?

geom_bar() está pensado para contar. Tú le das una columna en X (las categorías) y él cuenta cuántas filas caen en cada una y usa eso como altura. Por eso no le diste un y: lo calculó él. (Más adelante, si ya tienes los totales calculados, usarás geom_col(), que sí espera un y.)

aes(): mapear vs. fijar un valor

Esta es la distinción que más confunde al empezar, y entenderla te ahorra horas. Hay dos formas de dar color (o cualquier propiedad visual) a un gráfico:

1. Mapear (color depende de una columna) → va dentro de aes():

ggplot(pepito_df, aes(x = way, fill = way)) +
geom_bar()

Aquí fill = way significa “pinta cada barra según su valor de way”. El color depende de los datos: cada categoría recibe un color distinto y aparece una leyenda.

2. Fijar (un color fijo para todo) → va fuera de aes(), dentro del geom_:

ggplot(pepito_df, aes(x = way)) +
geom_bar(fill = "steelblue")

Aquí todas las barras son azules. El color no depende de los datos, es una decisión estética constante, así que va fuera de aes().

⚠️

El error de poner el color fijo dentro de `aes()`

Si escribes aes(fill = "steelblue") (con comillas, dentro de aes), no obtienes barras azules. ggplot2 interpreta "steelblue" como una “categoría” llamada literalmente steelblue, te pinta todo de un color raro y te agrega una leyenda absurda.

Regla simple:

  • ¿El color depende de una columna? → dentro de aes(), sin comillas: aes(fill = way).
  • ¿El color es fijo para todo? → fuera de aes(), con comillas: geom_bar(fill = "steelblue").

Sumando más capas

Ahora veamos cómo crece un gráfico agregando capas con +. Partimos del básico y le ponemos títulos y un tema más limpio:

ggplot(pepito_df, aes(x = way, fill = way)) +
geom_bar() +
labs(
title = "¿Pépito sale o entra más?",
x = "Dirección",
y = "Número de eventos",
fill = "Sentido"
) +
theme_minimal()

Lee las capas de arriba a abajo:

  • ggplot(...) + aes(...) — los datos y el mapeo.
  • geom_bar() — las barras.
  • labs(...) — los textos: title (título), x e y (nombres de ejes), fill (título de la leyenda).
  • theme_minimal() — un tema con fondo blanco y rejilla suave, mucho más limpio que el gris por defecto.

Cada + agregó una transparencia encima. Así se construye cualquier gráfico de ggplot2, por complejo que sea: empiezas simple y vas sumando.

Gráfico de barras con el número de entradas (in) y salidas (out) de Pépito
Resultado esperado. Tu primer gráfico con título, ejes y tema. Las entradas y salidas son casi iguales: para volver, primero hay que salir.

Construye de a poco

No intentes escribir el gráfico final de una sola vez. Empieza con ggplot(...) + geom_...(), mira el resultado, y agrega una capa a la vez. Si algo se ve raro, sabes que fue la última capa que pusiste. Esta es la forma más rápida de trabajar y de aprender.

El patrón mental

Cuando quieras hacer cualquier gráfico, hazte estas tres preguntas en orden:

  1. ¿Qué datos? → eso va en ggplot(datos, ...).
  2. ¿Qué columna en cada parte visual? (X, Y, color…) → eso va en aes(...).
  3. ¿Con qué forma los dibujo? → eso es el geom_.

Responder esas tres preguntas es hacer el gráfico. Todo lo demás (escalas, etiquetas, temas) es pulido.

Ejercicio

🧪

Ejercicio 5 — Domina el esqueleto

  1. Haz el gráfico de barras más simple de way (solo ggplot + geom_bar).
  2. Ahora píntalo: agrega fill = way dentro de aes(). Observa la leyenda que aparece.
  3. Cambia de idea: quita el fill de aes() y pon geom_bar(fill = "darkorange"). ¿Qué diferencia ves?
  4. Agrega labs() con un título y nombres de ejes, y theme_minimal().
  5. A propósito, escribe aes(fill = "darkorange") (con comillas, dentro de aes) y observa el resultado raro. Así reconocerás ese error cuando te pase de verdad.
Ver solución
library(ggplot2)
# 1. El más simple
ggplot(pepito_df, aes(x = way)) +
geom_bar()
# 2. Color que depende de los datos (dentro de aes)
ggplot(pepito_df, aes(x = way, fill = way)) +
geom_bar()
# 3. Color fijo (fuera de aes)
ggplot(pepito_df, aes(x = way)) +
geom_bar(fill = "darkorange")
# 4. Con etiquetas y tema
ggplot(pepito_df, aes(x = way, fill = way)) +
geom_bar() +
labs(title = "¿Pépito sale o entra más?",
x = "Dirección", y = "Número de eventos", fill = "Sentido") +
theme_minimal()
# 5. El error a propósito: barras de un color raro + leyenda absurda
ggplot(pepito_df, aes(x = way)) +
geom_bar(aes(fill = "darkorange"))

Lo que deberías notar:

  • En el (2), cada barra tiene su color y aparece una leyenda (porque el color mapea una columna).
  • En el (3), todas las barras son naranjas y no hay leyenda (el color es fijo).
  • En el (5), las barras salen de un color cualquiera y aparece una leyenda que dice “darkorange” como si fuera una categoría. Ese es exactamente el síntoma de haber metido un color fijo dentro de aes().

Si entendiste la diferencia entre mapear (dentro de aes) y fijar (fuera de aes), ya tienes la mitad de ggplot2 dominada.

06

Tu primer gráfico: barras con geom_bar

El objetivo del módulo

Ya conoces la gramática. Ahora vamos a usarla para responder una pregunta real: ¿a qué hora del día está más activo Pépito? En el camino aprenderás a controlar las barras: agrupar por color, separarlas o apilarlas, y ajustar el eje X.

De categorías a números: la hora del día

En el módulo 5 graficamos way, que tiene solo dos valores (in/out). Ahora usaremos la columna hour, que va de 0 a 23. Empecemos simple, contando los eventos por hora:

library(ggplot2)
ggplot(pepito_df, aes(x = hour)) +
geom_bar()

Obtienes 24 barras, una por hora. Ya se nota la rutina del gato: pocas barras de madrugada (Pépito duerme) y barras altas durante el día.

Separar por sentido con fill

La pregunta se pone más interesante si distinguimos entradas de salidas. Para eso mapeamos way al color de relleno (fill), como aprendimos:

ggplot(pepito_df, aes(x = hour, fill = way)) +
geom_bar()

Esto apila las barras: dentro de cada hora, una parte es in y otra out, una encima de la otra. Está bien, pero comparar alturas apiladas es difícil. Mejor pongámoslas lado a lado.

position: apilar, separar o normalizar

El argumento position de geom_bar() controla qué pasa cuando hay varios grupos por barra. Hay tres opciones que conviene conocer:

# Lado a lado (lo que queremos): dos barritas por hora
ggplot(pepito_df, aes(x = hour, fill = way)) +
geom_bar(position = "dodge")
  • position = "stack" (el de por defecto) — apila los grupos uno sobre otro. Bueno para ver el total.
  • position = "dodge" — los pone lado a lado. Bueno para comparar grupos entre sí. Es el que usaremos.
  • position = "fill" — apila pero estira todas las barras a la misma altura (100%), para ver proporciones. Bueno para “¿qué porcentaje de eventos a esta hora son salidas?”.
🤔

Detente y piensa

Elegir position no es decoración: cambia la pregunta que responde el gráfico. stack responde “¿cuántos eventos hubo?”; dodge responde “¿hubo más entradas o salidas a esta hora?”; fill responde “¿qué proporción?”. Elige según lo que quieras mostrar.

El gráfico completo

Juntemos todo y pulámoslo. Este es el gráfico de actividad por hora, completo:

ggplot(pepito_df, aes(x = hour, fill = way)) +
geom_bar(position = "dodge") +
scale_x_continuous(breaks = 0:23) +
labs(
title = "Actividad de Pépito por hora del día",
x = "Hora del día",
y = "Número de eventos",
fill = "Sentido"
) +
theme_minimal()

La capa nueva es scale_x_continuous(breaks = 0:23). Sirve para controlar las marcas del eje X:

  • scale_x_continuous se usa cuando el eje X es numérico (como hour).
  • breaks = 0:23 le dice “pon una marca en cada número del 0 al 23”. (0:23 es la forma corta de R para “todos los enteros de 0 a 23”.)

Sin esta línea, ggplot2 pone marcas cada 5 horas (0, 5, 10…) y cuesta leer la hora exacta. Con ella, ves las 24 horas etiquetadas.

Barras de actividad de Pépito por hora del día, separadas en entradas y salidas
Resultado esperado. Actividad por hora, con entradas (salmón) y salidas (turquesa) lado a lado gracias a position = dodge. La rutina del gato salta a la vista.
💡

Lo que revela el gráfico

Vas a ver dos cosas: que la actividad se concentra en las horas de luz y cae de madrugada, y que las salidas (out) tienden a dominar más temprano y las entradas (in) un poco más tarde — lo lógico en un gato que sale a rondar y vuelve después. Acabas de leer la rutina de un gato en un gráfico. 🐱

Errores comunes con barras

⚠️

Error: `geom_bar()` me pide un `y` o me da error con `stat`

Si ya tienes los totales calculados (por ejemplo, una tabla con una columna n) y se los pasas a geom_bar(), choca, porque geom_bar() quiere contar él mismo. En ese caso usa geom_col(), que dibuja la altura que tú le das:

conteo <- pepito_df |> count(hour)
ggplot(conteo, aes(x = hour, y = n)) +
geom_col()

Regla: geom_bar() cuenta filas por ti; geom_col() dibuja totales que ya calculaste.

⚠️

Error: las barras de `dodge` quedan de distinto ancho

Si algún grupo no tiene datos en cierta categoría, con position = "dodge" las barras pueden verse desalineadas. Para barras de ancho consistente, usa position = position_dodge(preserve = "single"). No te preocupes por esto ahora; tenlo guardado por si te pasa.

`scale_x_continuous` vs `scale_x_discrete`

  • Si tu eje X es un número (hour), usa scale_x_continuous().
  • Si tu eje X es una categoría/texto (way, weekday), usa scale_x_discrete().

Poner el scale_ equivocado es una causa silenciosa de “no me hace caso el breaks”. Mira el tipo de tu columna con glimpse() si tienes dudas.

Ejercicio

🧪

Ejercicio 6 — Explora las barras

  1. Haz el gráfico de actividad por hora con position = "dodge", las marcas del 0 al 23, títulos y theme_minimal().
  2. Cambia position a "fill". ¿Qué pregunta responde ahora el gráfico?
  3. Cambia position a "stack". ¿Cuándo te serviría más esta versión?
  4. Extra: haz un gráfico de barras de weekday (día de la semana). ¿Necesitas scale_x_continuous o scale_x_discrete para este? ¿Por qué?
Ver solución
library(ggplot2)
# 1. Dodge (lado a lado)
ggplot(pepito_df, aes(x = hour, fill = way)) +
geom_bar(position = "dodge") +
scale_x_continuous(breaks = 0:23) +
labs(title = "Actividad de Pépito por hora del día",
x = "Hora del día", y = "Número de eventos", fill = "Sentido") +
theme_minimal()
# 2. Fill (proporción 0–100%)
ggplot(pepito_df, aes(x = hour, fill = way)) +
geom_bar(position = "fill") +
scale_x_continuous(breaks = 0:23) +
labs(title = "Proporción de entradas y salidas por hora",
x = "Hora del día", y = "Proporción", fill = "Sentido") +
theme_minimal()
# 4. Día de la semana (eje categórico)
ggplot(pepito_df, aes(x = weekday)) +
geom_bar(fill = "steelblue") +
labs(title = "Actividad por día de la semana",
x = "Día", y = "Número de eventos") +
theme_minimal()

Respuestas:

  • (2) fill responde “de los eventos de esta hora, ¿qué porcentaje son entradas y qué porcentaje salidas?”. Útil para ver el balance, no la cantidad.
  • (3) stack sirve cuando te importa el total por hora y, de paso, su composición.
  • (4) weekday es una categoría (texto ordenado), así que el eje es discreto: usarías scale_x_discrete() si quisieras tocarlo. scale_x_continuous no funcionaría aquí porque el eje no es numérico.
07

Series de tiempo: la actividad a lo largo de los años

El objetivo del módulo

La gracia del dataset de Pépito es que abarca casi 14 años. En este módulo vamos a mirar cómo cambia su actividad a lo largo del tiempo: gráficos donde el eje X es una fecha. Aprenderás dos formas de hacerlo: geom_freqpoly() (que cuenta solo) y geom_line() (que dibuja totales que tú calculas).

Contar eventos por día con geom_freqpoly()

Queremos ver cuántos eventos hubo cada día, a lo largo de todos los años. geom_freqpoly() es perfecto: toma una columna continua (como la fecha), la divide en tramos y dibuja una línea con la cantidad de eventos en cada tramo.

library(ggplot2)
ggplot(pepito_df, aes(x = date)) +
geom_freqpoly(binwidth = 1) +
labs(
title = "Actividad de Pépito a lo largo del tiempo",
x = "Fecha",
y = "Eventos por día"
) +
theme_minimal()

Línea por línea:

  • aes(x = date) — el eje X es la fecha. No damos y: igual que geom_bar(), este geom cuenta solo.
  • geom_freqpoly(binwidth = 1) — agrupa los datos en tramos de 1 día (binwidth = 1, porque la fecha se mide en días) y une los conteos con una línea. Resultado: un punto por día, conectado.
  • El resto son etiquetas y tema, ya conocidos.
Línea de eventos por día de Pépito a lo largo de los años
Resultado esperado. Eventos por día a lo largo del tiempo. Los valles donde la línea cae a cero suelen ser periodos sin registro (sensor apagado o vacaciones).
💡

`geom_freqpoly` es como un `geom_histogram`, pero con línea

Los dos hacen lo mismo —cuentan cuántos datos caen en cada tramo— pero uno lo dibuja con barras (geom_histogram) y el otro con una línea (geom_freqpoly). Para series largas de tiempo, la línea suele verse más limpia. Veremos histogramas en detalle en el módulo 8.

🤔

Detente y piensa

Mira el gráfico: ¿hay tramos donde la línea cae a cero? Esos podrían ser periodos en que el sensor estuvo apagado, o el gato/dueño de vacaciones. Los huecos en los datos también cuentan una historia — y es importante notarlos antes de sacar conclusiones.

La otra forma: calcular y dibujar con geom_line()

geom_freqpoly() cuenta por ti, pero muchas veces querrás calcular tú un total (por día, por mes, por año) y luego dibujarlo. Ese patrón —resumir primero, graficar después— es uno de los más usados en análisis de datos. Vamos a verlo contando eventos por día con dplyr y dibujándolos con geom_line():

library(dplyr)
library(ggplot2)
# Paso 1: resumir — contar eventos por día
por_dia <- pepito_df |> count(date)
# Paso 2: graficar — date en X, n (el conteo) en Y
ggplot(por_dia, aes(x = date, y = n)) +
geom_line() +
labs(title = "Eventos por día", x = "Fecha", y = "Número de eventos") +
theme_minimal()

La diferencia clave con geom_freqpoly: aquí sí damos y = n, porque ya calculamos el conteo con count(date). geom_line() no cuenta nada; solo une con una línea los puntos (x, y) que le pasas.

💡

`count()` crea una columna llamada `n`

Cuando haces pepito_df |> count(date), dplyr te devuelve una tabla con dos columnas: date (cada fecha distinta) y n (cuántas filas había de esa fecha). Por eso en el aes() usamos y = n. Si quieres otro nombre, puedes usar count(date, name = "eventos").

Suavizar la mirada: agrupar por mes

Un punto por día durante 14 años es mucho ruido. A veces conviene mirar más “de lejos”, por ejemplo por mes. Aquí combinamos lubridate y dplyr:

library(lubridate)
por_mes <- pepito_df |>
mutate(mes = floor_date(date, "month")) |> # redondea cada fecha al 1º de su mes
count(mes)
ggplot(por_mes, aes(x = mes, y = n)) +
geom_line() +
geom_point() +
labs(title = "Eventos por mes", x = "Mes", y = "Número de eventos") +
theme_minimal()

Dos cosas nuevas:

  • floor_date(date, "month") — “redondea hacia abajo” cada fecha al primer día de su mes. Así todas las fechas de noviembre de 2011 se vuelven 2011-11-01 y se pueden contar juntas.
  • geom_point() sumado a geom_line() — ¡otra vez las capas! Dibujamos la línea y encima los puntos. Esto muestra muy bien cómo se apilan capas: ambos geoms comparten el mismo aes() del ggplot().

Cambia el nivel de detalle con `floor_date`

floor_date(date, "week"), "month", "quarter", "year"… Cambiando una sola palabra controlas cuán “de cerca” o “de lejos” miras la serie. Es una de las herramientas más útiles para series de tiempo: empieza de lejos (por año o mes) para ver la tendencia general, y acércate (por día) solo donde necesites el detalle.

Errores comunes en series de tiempo

⚠️

Error: la línea hace zigzags imposibles

geom_line() une los puntos en el orden en que vienen en la tabla. Si tus datos no están ordenados por fecha, la línea salta para adelante y atrás formando una maraña. Solución: ordena por la columna del eje X antes de graficar:

por_dia <- pepito_df |> count(date) |> arrange(date)

Con count() los datos suelen venir ya ordenados, pero si calculas el resumen de otra forma, ordena siempre antes de geom_line().

⚠️

Error: el eje X muestra números raros en vez de fechas

Si el eje X aparece como números grandes (tipo 15000) en lugar de fechas, es que la columna no es de tipo fecha, sino número o texto. Vuelve al módulo 4 y asegúrate de haber creado date con as_date(). Compruébalo con glimpse(): debe decir <date>.

Controlar el formato del eje de fechas

Para elegir cómo se ven las fechas en el eje (por ejemplo, mostrar solo el año), existe scale_x_date():

scale_x_date(date_breaks = "2 years", date_labels = "%Y")

date_breaks = "2 years" pone una marca cada dos años; date_labels = "%Y" muestra solo el año. Guárdalo para cuando un eje de fechas se vea apretado.

Ejercicio

🧪

Ejercicio 7 — Mira el tiempo a distintas escalas

  1. Haz el gráfico de eventos por día con geom_freqpoly(binwidth = 1).
  2. Calcula los eventos por mes con floor_date + count, y grafícalos con geom_line() + geom_point().
  3. Calcula los eventos por año (cambia "month" por "year" en floor_date) y grafícalos. ¿Se nota algún año con menos datos?
  4. Extra: al gráfico anual, agrégale scale_x_date(date_breaks = "1 year", date_labels = "%Y") para etiquetar cada año.
Ver solución
library(dplyr)
library(lubridate)
library(ggplot2)
# 1. Por día con freqpoly
ggplot(pepito_df, aes(x = date)) +
geom_freqpoly(binwidth = 1) +
labs(title = "Eventos por día", x = "Fecha", y = "Eventos") +
theme_minimal()
# 2. Por mes
por_mes <- pepito_df |>
mutate(mes = floor_date(date, "month")) |>
count(mes)
ggplot(por_mes, aes(x = mes, y = n)) +
geom_line() + geom_point() +
labs(title = "Eventos por mes", x = "Mes", y = "Eventos") +
theme_minimal()
# 3 y 4. Por año, con eje etiquetado
por_anio <- pepito_df |>
mutate(anio = floor_date(date, "year")) |>
count(anio)
ggplot(por_anio, aes(x = anio, y = n)) +
geom_line() + geom_point() +
scale_x_date(date_breaks = "1 year", date_labels = "%Y") +
labs(title = "Eventos por año", x = "Año", y = "Eventos") +
theme_minimal()

Sobre el (3): es normal que el primer y el último año tengan menos eventos, porque los datos empiezan en noviembre de 2011 (año incompleto) y terminan cuando se descargó el archivo (último año también incompleto). Detectar años incompletos es importante: si no, podrías concluir erróneamente que “Pépito salió mucho menos en 2011” cuando en realidad solo hay dos meses de datos ese año.

08

Distribuciones: histogramas y densidad

El objetivo del módulo

Hasta ahora contamos eventos. Pero la pregunta más rica del dataset es otra: ¿cuánto tiempo pasa Pépito fuera de casa? Para responderla necesitamos algo nuevo: emparejar cada salida con la entrada siguiente y medir la diferencia. Eso nos dará las “sesiones de paseo”, y con ellas haremos histogramas y densidades: los gráficos para ver cómo se distribuye una variable numérica.

Este módulo mezcla un poco más de preparación de datos con gráficos nuevos. Vale la pena: el resultado es la base del gran final.

El problema: cada fila es un evento, no una salida completa

Nuestros datos tienen una fila por evento (salió, o entró). Pero una “salida al mundo” son en realidad dos eventos: un out y, más tarde, el in que lo cierra. Para medir la duración necesitamos mirar cada fila y la siguiente.

La función que mira “la fila siguiente” es lead() de dplyr. Veámosla primero con las transiciones:

library(dplyr)
pepito_df |>
mutate(next_way = lead(way)) |>
count(way, next_way)

lead(way) crea una columna con el valor de way de la fila siguiente. Así podemos contar qué pasa después de cada evento: ¿una salida suele ir seguida de una entrada? El conteo te muestra las combinaciones (out → in, out → out, etc.).

⚠️

Antes de usar `lead()`: ¡ORDENA los datos!

lead() toma “la fila de abajo” tal como está en la tabla. Si las filas no están en orden cronológico, “la siguiente” no será el evento que ocurrió después en el tiempo, y todo el cálculo quedará mal.

Por eso, lo primero es ordenar por fecha-hora:

pepito_df <- pepito_df |> arrange(datetime_fr)

arrange() ordena las filas; aquí, de la más antigua a la más reciente. Nunca uses lead() o lag() sin ordenar primero. Es el error silencioso más peligroso de este módulo.

Construir las sesiones de paseo

Ahora sí, armamos la tabla de sesiones. La lógica es: para cada fila que sea una salida (out) cuyo evento siguiente sea una entrada (in), calculamos cuánto tiempo pasó entre ambos.

library(dplyr)
# 1. Ordenar cronológicamente (imprescindible)
pepito_df <- pepito_df |> arrange(datetime_fr)
# 2. Construir las sesiones
sessions <- pepito_df |>
mutate(
next_way = lead(way), # el sentido del evento siguiente
next_time = lead(datetime_fr) # la hora del evento siguiente
) |>
filter(way == "out", next_way == "in") |> # solo: salió y luego entró
mutate(
duracion_min = as.numeric(difftime(next_time, datetime_fr, units = "mins")),
duracion_horas = duracion_min / 60,
salida = datetime_fr,
entrada = next_time
) |>
select(salida, entrada, duracion_min, duracion_horas)

Léelo como una receta encadenada con |>:

  1. mutate(next_way = ..., next_time = ...) — agregamos dos columnas: el sentido y la hora del evento siguiente.
  2. filter(way == "out", next_way == "in") — nos quedamos solo con las filas que son una salida seguida de una entrada. Una coma dentro de filter() significa “y” (ambas condiciones). Fíjate en el == doble: en R, == compara, mientras que = asigna. Para filtrar siempre va ==.
  3. mutate(duracion_min = ...) — calculamos la duración. difftime(fin, inicio, units = "mins") da la diferencia de tiempo en minutos; as.numeric() la convierte en un número normal con el que podemos graficar. También guardamos salida y entrada con nombres claros.
  4. select(...) — nos quedamos solo con las cuatro columnas que nos interesan.

El resultado, sessions, es una tabla donde cada fila es una salida completa de Pépito, con cuánto duró.

💡

`difftime()`, tu calculadora de tiempo

Restar dos fechas-hora a mano es complicado (¿horas?, ¿días?, ¿segundos?). difftime(a, b, units = "mins") lo resuelve: te da la diferencia exacta en la unidad que pidas ("mins", "hours", "days"…). Envolverla en as.numeric() es importante para que ggplot2 la trate como un número y no como un objeto de tiempo raro.

Limpiar sesiones imposibles

Los datos reales tienen ruido. A veces un evento se pierde (el sensor falla) y queda una “sesión” de duración negativa o de varios días. Las descartamos:

sessions <- sessions |>
filter(
duracion_min > 0, # nada de duraciones negativas o cero
duracion_horas < 24 # descarta paseos "de más de un día" (evento perdido)
)
💡

¿Y si sale de noche y vuelve de madrugada (otro día)?

Buena duda: ¿una salida que cruza la medianoche se calcula bien? Sí. La duración se obtiene con difftime(next_time, datetime_fr), y esas dos columnas son fechas-hora completas (día + hora), no solo la hora. difftime resta los dos instantes reales, así que cruzar la medianoche no importa: si sale el 31-dic a las 23:30 y vuelve el 1-ene a las 02:00, la duración es 150 min (2,5 horas), exacta.

Lo único a tener presente es a qué momento se le atribuye la salida: usamos salida (el evento out), por lo que esa sesión cuenta en el día, mes, año y hora en que salió (31-dic, hora 23), no en que volvió. Es la convención natural, y por eso más adelante agrupamos por la fecha de salida. El filtro duracion_horas < 24 no afecta estas salidas nocturnas normales (duran pocas horas); solo descarta los casos en que se perdió el evento de entrada y la “sesión” abarcaría días enteros.

🤔

Detente y piensa

Decidir qué datos descartar es una decisión de análisis, no solo técnica. Aquí asumimos que un gato no pasa más de 24 horas seguidas fuera; si pasa, probablemente se perdió el evento de entrada. Es una suposición razonable, pero siempre conviene anotar qué filtraste y por qué, para que tu análisis sea transparente.

Por fin: el histograma “¿cuánto tiempo pasa fuera?”

Un histograma divide una variable numérica en tramos y cuenta cuántos datos caen en cada uno. Es el gráfico para ver la forma de una distribución: ¿la mayoría de los paseos son cortos? ¿hay algunos larguísimos?

library(ggplot2)
ggplot(sessions, aes(x = duracion_min)) +
geom_histogram(binwidth = 15) + # tramos de 15 minutos
labs(
title = "¿Cuánto tiempo pasa Pépito fuera?",
x = "Minutos fuera",
y = "Número de salidas"
) +
theme_minimal()
  • aes(x = duracion_min) — la variable que queremos distribuir. Solo X; el Y (el conteo) lo calcula el histograma.
  • geom_histogram(binwidth = 15) — agrupa en tramos de 15 minutos. El ancho del tramo lo eliges tú con binwidth.

Vas a ver una distribución con una cola larga: muchísimos paseos cortos y unos pocos muy largos. Eso aplasta el gráfico hacia la izquierda.

Histograma de la duración en minutos de las salidas de Pépito, con cola larga a la derecha
Resultado esperado. La distribución del tiempo fuera: muchísimas salidas cortas y una cola larga de pocas salidas muy largas (binwidth = 15).
⚠️

`binwidth` vs `bins`: elige UNO

geom_histogram() tiene dos formas de definir los tramos:

  • binwidth = 15 — fija el ancho de cada tramo (15 minutos). Más interpretable.
  • bins = 40 — fija el número de tramos (40 en total).

Usa uno o el otro, no ambos. Y siempre define alguno: si no, ggplot2 usa 30 tramos por defecto y te avisa con un mensaje (stat_bin() using bins = 30. Pick better value...). Ese mensaje no es un error, es un recordatorio de que elijas tú el tramo.

Domando la cola larga con escala logarítmica

Cuando hay pocos valores enormes y muchos pequeños, una escala logarítmica en el eje X reparte mejor el espacio y deja ver la forma real:

ggplot(sessions, aes(x = duracion_min)) +
geom_histogram(bins = 40) +
scale_x_log10() +
labs(
title = "Duración de las salidas (escala log)",
x = "Minutos fuera (log)",
y = "Salidas"
) +
theme_minimal()

La capa nueva es scale_x_log10(): comprime el eje X de forma logarítmica. Ahora la distribución se ve como una “campana” mucho más legible, y se nota la duración típica de un paseo.

⚠️

`scale_x_log10()` y los ceros

El logaritmo de 0 (o de un número negativo) no existe. Si tu variable tiene ceros o negativos, scale_x_log10() los descarta y te avisa con un warning. Por eso fue importante el filtro duracion_min > 0 de antes: deja la variable lista para la escala log.

Densidad: entradas vs. salidas a lo largo del día

Una curva de densidad es como un histograma “suavizado”: en vez de barras, una curva continua que muestra dónde se concentran los datos. Sirve muy bien para comparar dos grupos. Volvamos a pepito_df para comparar a qué horas sale y a qué horas entra:

ggplot(pepito_df, aes(x = hour, fill = way)) +
geom_density(alpha = 0.5) +
scale_x_continuous(breaks = seq(0, 24, 2)) +
labs(
title = "Distribución horaria: entradas vs salidas",
x = "Hora del día",
y = "Densidad",
fill = "Sentido"
) +
theme_minimal()

Dos detalles nuevos:

  • geom_density(alpha = 0.5) — dibuja una curva por cada valor de way. alpha = 0.5 la hace medio transparente, para que se vean las dos curvas aunque se solapen. alpha va de 0 (invisible) a 1 (opaco) y va fuera de aes(), porque es un valor fijo.
  • scale_x_continuous(breaks = seq(0, 24, 2)) — marcas cada 2 horas. seq(0, 24, 2) genera la secuencia 0, 2, 4, …, 24.

Las dos curvas revelan el patrón: las salidas se concentran en ciertas horas y las entradas en otras, un poco más tarde.

`alpha` lo arregla casi todo cuando las cosas se tapan

Cada vez que dos formas se solapen y no se distingan (curvas de densidad, nubes de puntos densas, barras encimadas), prueba bajar el alpha. Es el truco más rápido para que un gráfico saturado vuelva a ser legible.

Un vistazo numérico con summary()

Antes de seguir, conviene mirar los números crudos. summary() da un resumen rápido de cada columna:

summary(sessions)

Te muestra el mínimo, máximo, promedio y la mediana de duracion_min. Mira la mediana: suele ser mucho menor que el promedio, justo porque la cola larga estira el promedio hacia arriba. Por eso, para “el tiempo típico fuera”, la mediana es más honesta que el promedio — algo que usaremos en el mapa de calor final.

Ejercicio

🧪

Ejercicio 8 — Construye las sesiones y su distribución

  1. Ordena pepito_df por datetime_fr.
  2. Construye la tabla sessions (salida → entrada, con duracion_min).
  3. Filtra las sesiones imposibles (duracion_min > 0, duracion_horas < 24).
  4. Haz el histograma de duracion_min con binwidth = 15.
  5. Haz la versión con scale_x_log10().
  6. Ejecuta summary(sessions) y compara la mediana con el promedio de duracion_min. ¿Cuál es mayor? ¿Por qué crees que pasa?
Ver solución
library(dplyr)
library(ggplot2)
# 1–3. Construir y limpiar sesiones
pepito_df <- pepito_df |> arrange(datetime_fr)
sessions <- pepito_df |>
mutate(next_way = lead(way), next_time = lead(datetime_fr)) |>
filter(way == "out", next_way == "in") |>
mutate(
duracion_min = as.numeric(difftime(next_time, datetime_fr, units = "mins")),
duracion_horas = duracion_min / 60,
salida = datetime_fr,
entrada = next_time
) |>
select(salida, entrada, duracion_min, duracion_horas) |>
filter(duracion_min > 0, duracion_horas < 24)
# 4. Histograma normal
ggplot(sessions, aes(x = duracion_min)) +
geom_histogram(binwidth = 15) +
labs(title = "¿Cuánto tiempo pasa Pépito fuera?",
x = "Minutos fuera", y = "Número de salidas") +
theme_minimal()
# 5. Con escala logarítmica
ggplot(sessions, aes(x = duracion_min)) +
geom_histogram(bins = 40) +
scale_x_log10() +
labs(title = "Duración de las salidas (escala log)",
x = "Minutos fuera (log)", y = "Salidas") +
theme_minimal()
# 6. Resumen
summary(sessions)

Sobre la mediana vs. el promedio: el promedio es mayor que la mediana. Esto pasa porque unas pocas salidas larguísimas (la cola) tiran del promedio hacia arriba, mientras que la mediana —el valor del medio— no se deja arrastrar por esos extremos. Por eso, para describir “lo normal” en datos con cola larga, la mediana es la mejor amiga. Lo recordaremos en el módulo 11.

09

Comparar grupos: boxplots

El objetivo del módulo

Un histograma muestra la distribución de una variable. Pero a menudo queremos comparar una distribución entre varios grupos: ¿pasa Pépito más rato fuera unos años que otros? ¿depende de la hora a la que sale? El gráfico perfecto para eso es el boxplot (diagrama de caja).

¿Qué muestra un boxplot?

Antes de dibujarlo, entiende qué cuenta. Una “caja” resume una distribución en cinco números:

💡

Anatomía de una caja

  • La línea del medio de la caja es la mediana (el valor central: la mitad de los datos está por debajo).
  • Los bordes de la caja son el primer y tercer cuartil: dentro de la caja está el 50% central de los datos.
  • Las líneas que salen de la caja (los “bigotes”) llegan hasta los valores más extremos razonables.
  • Los puntos sueltos más allá de los bigotes son posibles valores atípicos (outliers).

En una sola figura compacta puedes comparar muchos grupos de un vistazo: dónde está su centro y cuán dispersos son.

Boxplot por año

Queremos una caja por cada año. Primero necesitamos una columna de año en sessions:

library(lubridate)
sessions$year <- year(sessions$salida)

year() extrae el año de la fecha de salida. Ahora el gráfico:

library(ggplot2)
ggplot(sessions, aes(x = factor(year), y = duracion_min)) +
geom_boxplot() +
scale_y_log10() +
labs(
title = "¿Pépito sale más o menos con los años?",
x = "Año",
y = "Minutos fuera (escala log)"
) +
theme_minimal()

Hay dos detalles que debes entender bien:

  • x = factor(year) — envolvemos year en factor(). ¿Por qué? Porque el año es un número (2011, 2012…), y si lo dejamos como número, ggplot2 cree que el eje X es continuo y dibuja una sola caja gigante. factor() convierte el año en categorías separadas, una por año, y así obtenemos una caja por año. Este es el truco clave de los boxplots.
  • scale_y_log10() — como las duraciones tienen cola larga (módulo 8), la escala logarítmica en el eje Y hace que las cajas se vean proporcionadas en vez de aplastadas abajo.
Boxplots de la duración de las salidas de Pépito, una caja por año, en escala logarítmica
Resultado esperado. Una caja por año (gracias a factor(year)). La línea central es la mediana; los puntos sueltos, salidas atípicamente largas. Ojo con el primer y último año: están incompletos.
⚠️

El error más típico del boxplot: una sola caja gigante

Si tu boxplot muestra una única caja cuando esperabas varias, casi siempre es porque el eje X es numérico continuo. La solución es convertirlo en categoría con factor():

aes(x = factor(year), y = duracion_min) # ✅ una caja por año
aes(x = year, y = duracion_min) # ❌ una sola caja para todo

Esto aplica siempre que tu “grupo” sea un número (años, meses, horas): envuélvelo en factor().

Comprobaciones rápidas antes de concluir

Antes de leer un gráfico, conviene revisar los datos crudos para no sacar conclusiones falsas. Tres comprobaciones útiles:

sessions$year <- factor(year(sessions$salida))
table(sessions$year) # cuántas salidas hay por año
range(sessions$salida) # primera y última fecha
summary(sessions$duracion_min) # min, mediana, promedio, max
  • table() cuenta cuántas filas hay de cada año. Sirve para detectar años con pocos datos (el primero y el último suelen estar incompletos).
  • range() te da el primer y último instante registrado.
  • summary() resume la variable numérica.
🤔

Detente y piensa

Si un año tiene muy pocas salidas (porque está incompleto o el sensor estuvo apagado), su caja se construye con poca información y puede engañar. Por eso miramos table() antes: una caja “rara” en un año con 12 salidas no significa lo mismo que una en un año con 3.000. Mirar el conteo te protege de conclusiones apresuradas.

Boxplot por hora de salida

Cambiemos la pregunta: ¿los paseos que empiezan a cierta hora duran más? Quizás los de la noche son más largos que los del mediodía. Aquí encadenamos el cálculo y el gráfico con |>:

library(dplyr)
library(ggplot2)
sessions |>
mutate(hora_salida = hour(salida)) |>
ggplot(aes(x = factor(hora_salida), y = duracion_min)) +
geom_boxplot() +
scale_y_log10() +
labs(
title = "Duración de la salida según la hora en que sale",
x = "Hora de salida (0–23)",
y = "Minutos fuera (log)"
) +
theme_minimal()

Fíjate en algo importante de sintaxis: mientras preparamos datos usamos el pipe |>, pero al construir el gráfico cambiamos a +. Son dos “pegamentos” distintos: |> encadena pasos de datos, + encadena capas de ggplot2. Aquí conviven: mutate(...) |> ggplot(...) + geom_boxplot() + ....

⚠️

No mezcles `|>` y `+`

Un error clásico al encadenar todo junto es poner |> donde va + (o al revés). Recuerda:

  • Entre pasos de dplyr (filter, mutate, count, arrange…): usa |>.
  • Entre capas de ggplot2 (geom_, labs, scale_, theme_…): usa +.

El momento exacto del cambio es justo en ggplot(...): lo que viene antes se conecta con |>; lo que viene después (las capas) con +.

Errores comunes con boxplots

⚠️

Error: `Discrete value supplied to continuous scale` (o al revés)

Si pones scale_y_log10() pero el eje Y no es numérico, o scale_x_continuous() sobre un eje que hiciste factor(), ggplot2 reclama. Solución: la escala debe coincidir con el tipo del eje. El eje X de un boxplot suele ser discreto (factor), y el Y continuo (la variable numérica).

Añade los puntos por encima de la caja

A veces quieres ver los datos reales además del resumen. Súmale una capa de puntos con dispersión horizontal:

geom_boxplot(outlier.shape = NA) +
geom_jitter(width = 0.2, alpha = 0.2)

outlier.shape = NA oculta los outliers de la caja (para no dibujarlos dos veces) y geom_jitter() esparce los puntos para que no se encimen. Otra vez: capas sobre capas.

Ejercicio

🧪

Ejercicio 9 — Compara distribuciones

  1. Crea la columna year en sessions y haz el boxplot por año con factor(year) y scale_y_log10().
  2. A propósito, haz el mismo gráfico sin factor() (usando x = year). Observa la caja única y entiende el error.
  3. Revisa table(factor(year(sessions$salida))). ¿Qué años tienen menos salidas?
  4. Haz el boxplot de duracion_min por hora de salida (factor(hour(salida))).
  5. Extra: ¿a qué hora del día parecen empezar los paseos más largos? Mira las medianas de las cajas.
Ver solución
library(dplyr)
library(lubridate)
library(ggplot2)
# 1. Boxplot por año (correcto)
sessions$year <- year(sessions$salida)
ggplot(sessions, aes(x = factor(year), y = duracion_min)) +
geom_boxplot() +
scale_y_log10() +
labs(title = "¿Pépito sale más o menos con los años?",
x = "Año", y = "Minutos fuera (escala log)") +
theme_minimal()
# 2. El error a propósito: una sola caja
ggplot(sessions, aes(x = year, y = duracion_min)) +
geom_boxplot() +
scale_y_log10()
# 3. Conteo por año
table(factor(year(sessions$salida)))
# 4. Por hora de salida
sessions |>
mutate(hora_salida = hour(salida)) |>
ggplot(aes(x = factor(hora_salida), y = duracion_min)) +
geom_boxplot() +
scale_y_log10() +
labs(title = "Duración según la hora de salida",
x = "Hora de salida (0–23)", y = "Minutos fuera (log)") +
theme_minimal()

Lo que deberías observar:

  • En el (2), una sola caja confirma por qué factor() es indispensable.
  • En el (3), el primer y último año suelen tener menos salidas (datos incompletos), así que sus cajas hay que leerlas con cuidado.
  • En el (5), suele notarse que los paseos que empiezan de noche o madrugada tienden a durar más (mediana más alta) que los del mediodía. ¡Pépito es más aventurero de noche!
10

Tendencias y estaciones: geom_smooth y líneas

El objetivo del módulo

Con casi 14 años de datos, la pregunta natural es: ¿cambió la rutina de Pépito con el tiempo? ¿Sale más o menos que antes? ¿Influye la estación del año? En este módulo aprendemos dos herramientas: geom_smooth() (líneas de tendencia) y cómo dibujar varias líneas a la vez usando el color para distinguir grupos.

La tendencia general con geom_smooth()

Si dibujáramos un punto por cada salida (miles de puntos), veríamos una nube imposible de leer. geom_smooth() resuelve esto: traza una curva suave que sigue la tendencia general entre tanto punto.

library(ggplot2)
ggplot(sessions, aes(x = salida, y = duracion_min)) +
geom_smooth() +
scale_y_log10() +
labs(
title = "Tendencia del tiempo fuera (2011–2024)",
x = "Fecha",
y = "Minutos fuera (log)"
) +
theme_minimal()
  • aes(x = salida, y = duracion_min) — la fecha de salida contra la duración.
  • geom_smooth() — calcula y dibuja la curva de tendencia, con una banda gris alrededor que indica la incertidumbre (cuán segura es la curva en cada punto).
  • scale_y_log10() — otra vez, por la cola larga de las duraciones.

La curva te dice, de un vistazo, si la duración típica de los paseos sube, baja o se mantiene a lo largo de los años.

💡

¿Qué curva dibuja `geom_smooth()`?

Por defecto, con muchos datos usa un método llamado LOESS, que es una curva flexible que se adapta a la forma de los datos sin que tú le impongas una fórmula. Si quisieras una línea recta (tendencia lineal), se la pides con geom_smooth(method = "lm") (lm = modelo lineal). La curva flexible es buena para explorar; la recta, para resumir una tendencia simple.

⚠️

Mensaje (no error) de `geom_smooth()`

Al usar geom_smooth() verás en la consola algo como geom_smooth() using method = 'loess' and formula 'y ~ x'. No es un error, solo te informa qué método eligió. Puedes ignorarlo o, si quieres silenciarlo, especificar tú el método.

Preparar las estaciones del año

Ahora la pregunta estacional. Necesitamos clasificar cada salida según la estación. Como Pépito vive en el hemisferio norte, los meses de verano son junio–agosto. Para crear la columna usamos case_when(), que es como un “si esto, entonces aquello”:

library(dplyr)
library(lubridate)
sessions <- sessions |>
mutate(
anio = year(salida),
estacion = case_when(
month(salida) %in% c(12, 1, 2) ~ "Invierno",
month(salida) %in% 3:5 ~ "Primavera",
month(salida) %in% 6:8 ~ "Verano",
TRUE ~ "Otoño"
)
)

Entendamos case_when():

  • Cada línea tiene la forma condición ~ valor. Se lee: “si se cumple la condición, asigna este valor”.
  • month(salida) %in% c(12, 1, 2) — “si el mes es diciembre, enero o febrero”. El operador %in% pregunta “¿está dentro de esta lista?”.
  • 3:5 es la forma corta de c(3, 4, 5) (marzo a mayo).
  • TRUE ~ "Otoño" — la última línea es el “para todo lo demás”. Como TRUE siempre se cumple, atrapa cualquier caso que no haya calzado antes (los meses 9, 10, 11).
⚠️

En `case_when()`, el orden importa

case_when() evalúa las condiciones de arriba hacia abajo y se queda con la primera que se cumple. Por eso el TRUE ~ ... va siempre al final: si lo pusieras primero, atraparía todo y las demás condiciones nunca se evaluarían. Piensa en las condiciones como un colador: de la más específica a la más general.

Resumir por año y estación

Queremos un valor por cada combinación de año y estación: la mediana de minutos fuera. Aquí entra el dúo group_by() + summarise(), una de las parejas más usadas de dplyr:

resumen <- sessions |>
group_by(anio, estacion) |>
summarise(mediana = median(duracion_min), .groups = "drop")
  • group_by(anio, estacion) — divide la tabla en grupos, uno por cada combinación de año y estación.
  • summarise(mediana = median(duracion_min)) — para cada grupo, calcula la mediana y la guarda en una columna nueva, mediana. Usamos la mediana (y no el promedio) por lo que vimos en el módulo 8: resiste mejor las salidas extremas.
  • .groups = "drop" — desagrupa la tabla al terminar. Si lo omites, dplyr te deja la tabla agrupada y te muestra un mensaje algo confuso. Ponerlo es una buena costumbre.
💡

`group_by` + `summarise`: el patrón de oro para resumir

Esta combinación responde la pregunta “¿cuál es un número (promedio, mediana, total, conteo…) por cada grupo?”. La vas a usar en casi todos tus análisis. group_by decide los grupos; summarise calcula el número de cada uno.

Varias líneas en un gráfico con color

Ahora dibujamos una línea por estación, todas en el mismo gráfico, distinguidas por color:

library(ggplot2)
resumen |>
ggplot(aes(x = anio, y = mediana, color = estacion)) +
geom_line(linewidth = 1) +
geom_point() +
labs(
title = "Mediana de minutos fuera por estación y año",
x = "Año",
y = "Minutos fuera (mediana)",
color = "Estación"
) +
theme_minimal()

La clave está en color = estacion dentro de aes(): le decimos a ggplot2 “dibuja una línea distinta para cada estación y dales colores diferentes”. ggplot2 separa los datos por estación, dibuja una línea para cada una y agrega la leyenda automáticamente.

  • geom_line(linewidth = 1) — las líneas, un poco más gruesas (linewidth controla el grosor).
  • geom_point() — puntos sobre cada año, para marcar los valores reales.
Cuatro líneas, una por estación, mostrando la mediana de minutos fuera de Pépito por año
Resultado esperado. Una línea por estación gracias a color = estacion. El verano (púrpura) suele ser la estación de paseos más largos; el invierno (rojo), los más cortos.
⚠️

`color` vs `fill` en líneas

Para líneas y puntos se usa color (el color del trazo). fill (relleno) es para formas con área, como barras, cajas o densidades. Si una línea no cambia de color al usar fill = estacion, es porque usaste la propiedad equivocada: las líneas se pintan con color, no con fill.

⚠️

Error: todas mis líneas se conectan en una sola maraña

Si en vez de una línea por grupo ves una sola línea saltarina, es que ggplot2 no supo cómo agrupar. Mapear color = estacion normalmente basta (define los grupos solo). Si dibujas una sola línea de un color y aun así zigzaguea, revisa que los datos estén ordenados por el eje X (arrange(anio)).

Toques opcionales: etiquetar cada curva al final

Una leyenda obliga al lector a “ir y volver” entre las líneas y el recuadro de colores. Una alternativa más elegante es escribir el nombre de cada estación al final de su línea y quitar la leyenda. Lo hacemos en tres pasos.

Paso 1 — quedarnos con el último punto de cada estación. Para colocar la etiqueta al final, necesitamos saber dónde termina cada línea (su último año):

ultimos <- resumen |>
group_by(estacion) |>
filter(anio == max(anio)) |>
ungroup()

group_by(estacion) agrupa por estación y filter(anio == max(anio)) deja, dentro de cada grupo, solo la fila del año más reciente. El resultado, ultimos, tiene una fila por estación: justo donde irá cada etiqueta.

Paso 2 — dibujar las etiquetas y quitar la leyenda:

resumen |>
ggplot(aes(x = anio, y = mediana, color = estacion)) +
geom_line(linewidth = 1) +
geom_point() +
geom_text(
data = ultimos,
aes(label = estacion),
hjust = 0, nudge_x = 0.2,
fontface = "bold", show.legend = FALSE
) +
scale_x_continuous(expand = expansion(mult = c(0.02, 0.15))) +
labs(title = "Mediana de minutos fuera por estación y año",
x = "Año", y = "Minutos fuera (mediana)") +
theme_minimal() +
theme(legend.position = "none")

Lo nuevo, línea por línea:

  • geom_text(data = ultimos, ...) — dibuja texto usando otra tabla (ultimos), no la del ggplot(). Esto es muy útil: cada capa puede tener sus propios datos. Como ultimos también tiene color = estacion, cada etiqueta sale del color de su línea.
  • hjust = 0, nudge_x = 0.2 — alinea el texto a la izquierda y lo empuja un poco a la derecha, para que quede después del último punto y no encima.
  • scale_x_continuous(expand = expansion(mult = c(0.02, 0.15))) — agranda el margen derecho del eje (un 15%) para que quepan las etiquetas. Sin esto, los nombres se cortan en el borde.
  • theme(legend.position = "none") — quita la leyenda: ya no hace falta, porque cada línea está rotulada.

Un detalle con estos datos: en el último año las cuatro estaciones terminan muy cerca (todas con valores bajos, porque 2024 está incompleto), así que con geom_text los nombres se encimarían y se cortarían. La solución es ggrepel, que separa las etiquetas automáticamente.

Paso 3 — separar las etiquetas con ggrepel:

# pak::pak("ggrepel") # instalar una vez
library(ggrepel)
resumen |>
ggplot(aes(x = anio, y = mediana, color = estacion)) +
geom_line(linewidth = 1) +
geom_point() +
geom_text_repel(
data = ultimos, aes(label = estacion),
hjust = 0, nudge_x = 0.8, direction = "y",
box.padding = 0.5, min.segment.length = 0,
segment.color = "grey70", segment.size = 0.3,
max.overlaps = Inf, fontface = "bold", show.legend = FALSE
) +
scale_x_continuous(expand = expansion(mult = c(0.02, 0.28))) +
coord_cartesian(clip = "off") +
labs(title = "Mediana de minutos fuera por estación y año",
x = "Año", y = "Minutos fuera (mediana)") +
theme_minimal() +
theme(legend.position = "none",
plot.margin = margin(6, 20, 6, 6))

Detalle por detalle, lo que hace que no se encimen:

  • nudge_x = 0.8 + direction = "y" — empuja los nombres bien a la derecha y los separa solo en vertical.
  • box.padding = 0.5 — exige más espacio alrededor de cada texto, así Otoño e Invierno (que en 2024 quedan casi a la misma altura) se apartan.
  • min.segment.length = 0 + segment.color = "grey70" — dibuja una línea-guía gris desde cada nombre hasta su punto, para que se entienda cuál es cuál aunque queden cerca.
  • coord_cartesian(clip = "off") + más expand y plot.margin — permiten que las etiquetas se dibujen en el margen derecho sin cortarse.
Las cuatro líneas de estaciones con el nombre de cada estación escrito al final, separadas con ggrepel, sin leyenda
Resultado esperado. Misma información, sin leyenda: cada curva lleva su nombre al final, separado con ggrepel para que no se encimen ni se corten. Más directo de leer.

Otro extra: colores con sentido

Por defecto ggplot2 asigna colores automáticos. Puedes elegirlos tú con scale_color_manual() para que cada estación tenga un color “lógico”:

scale_color_manual(values = c(
Invierno = "#4F9DDE", # azul frío
Primavera = "#3FAE5A", # verde
Verano = "#E4572E", # naranja cálido
Otoño = "#E0A106" # ámbar
))

Agrégalo como una capa más (+ scale_color_manual(...)). Es un detalle pequeño que hace el gráfico mucho más intuitivo.

Ejercicio

🧪

Ejercicio 10 — Tendencias en el tiempo

  1. Haz la línea de tendencia general con geom_smooth() y scale_y_log10().
  2. Crea las columnas anio y estacion con case_when().
  3. Resume la mediana de duracion_min por año y estación con group_by + summarise.
  4. Dibuja una línea por estación usando color = estacion.
  5. Extra: cambia geom_smooth() por geom_smooth(method = "lm") en el primer gráfico. ¿La tendencia general sube o baja a lo largo de los años?
Ver solución
library(dplyr)
library(lubridate)
library(ggplot2)
# 1. Tendencia general
ggplot(sessions, aes(x = salida, y = duracion_min)) +
geom_smooth() +
scale_y_log10() +
labs(title = "Tendencia del tiempo fuera (2011–2024)",
x = "Fecha", y = "Minutos fuera (log)") +
theme_minimal()
# 2–4. Estaciones
resumen <- sessions |>
mutate(
anio = year(salida),
estacion = case_when(
month(salida) %in% c(12, 1, 2) ~ "Invierno",
month(salida) %in% 3:5 ~ "Primavera",
month(salida) %in% 6:8 ~ "Verano",
TRUE ~ "Otoño"
)
) |>
group_by(anio, estacion) |>
summarise(mediana = median(duracion_min), .groups = "drop")
resumen |>
ggplot(aes(x = anio, y = mediana, color = estacion)) +
geom_line(linewidth = 1) +
geom_point() +
labs(title = "Mediana de minutos fuera por estación y año",
x = "Año", y = "Minutos fuera (mediana)", color = "Estación") +
theme_minimal()
# 5. Tendencia lineal
ggplot(sessions, aes(x = salida, y = duracion_min)) +
geom_smooth(method = "lm") +
scale_y_log10() +
theme_minimal()

Sobre el extra: la tendencia suele mostrar que el verano es la estación de paseos más largos (más luz, mejor clima = más aventura felina), mientras que el invierno es la de paseos más cortos. Tiene todo el sentido del mundo para un gato. La recta general (method = "lm") te dará la dirección de fondo a lo largo de los 14 años.

11

El gran final: el mapa de calor

El objetivo del módulo

Llegamos al gran final. En este módulo construimos el gráfico que prometimos en el módulo 1: el mapa de calor que resume 14 años de la vida de Pépito en un solo cuadro. Lo haremos en dos pasos: primero un mapa de calor sencillo para aprender la técnica (geom_tile), y después la versión completa y pulida, capa por capa.

¿Qué es un mapa de calor?

Un mapa de calor (heatmap) es una cuadrícula donde cada celda representa la combinación de dos categorías (por ejemplo, un día y una hora), y el color de la celda codifica un valor numérico. Es ideal para responder “¿cuándo / dónde se concentra algo?” cuando tienes dos dimensiones a la vez.

El geom que dibuja celdas se llama geom_tile(): necesita un x, un y y un fill (el color que codifica el valor).

Calentamiento: ¿cuándo está activo Pépito?

Empecemos con un mapa de calor de día de la semana × hora, donde el color sea la cantidad de eventos. Primero contamos:

library(dplyr)
heat <- pepito_df |> count(weekday, hour)

count(weekday, hour) cuenta cuántos eventos hay para cada combinación de día de la semana y hora. El resultado tiene tres columnas: weekday, hour y n (el conteo). Ahora lo graficamos:

library(ggplot2)
ggplot(heat, aes(x = hour, y = weekday, fill = n)) +
geom_tile() +
scale_fill_viridis_c() +
scale_x_continuous(breaks = 0:23) +
labs(
title = "¿Cuándo está activo Pépito?",
x = "Hora del día",
y = "Día de la semana",
fill = "Eventos"
) +
theme_minimal()

Las piezas nuevas:

  • aes(x = hour, y = weekday, fill = n) — dos dimensiones en los ejes (hora y día) y el conteo n en el color (fill).
  • geom_tile() — dibuja un rectángulo por cada combinación, pintado según fill.
  • scale_fill_viridis_c() — la escala de color viridis, que va de morado oscuro a amarillo. Es la estrella de los mapas de calor (ya verás por qué). La c final es de continuous, porque n es un número continuo.
💡

¿Por qué viridis y no un degradado cualquiera?

La escala viridis está diseñada para que las diferencias de color se perciban de forma uniforme y para que siga siendo legible en blanco y negro y para personas con daltonismo. Por eso es la opción profesional por defecto en mapas de calor. Está incluida en ggplot2 moderno, así que no necesitas instalar nada extra: solo scale_fill_viridis_c().

🤔

Detente y piensa

Mira el patrón: las celdas amarillas (más eventos) se concentran en ciertas horas del día, bastante parecidas de lunes a domingo. ¿Hay alguna diferencia entre los días de semana y el fin de semana? Acabas de leer el “horario laboral” de un gato. 🐱

Mapa de calor del número de eventos de Pépito por día de la semana y hora del día
Resultado esperado. El mapa de calor de calentamiento: día de la semana × hora, con el color (viridis) indicando cuántos eventos hubo. Las horas activas se ven amarillas.

El gráfico estrella: minutos fuera por año y mes

Ahora el gran final. Queremos una celda por cada mes de cada año, coloreada según la mediana de minutos que Pépito pasó fuera. Primero el resumen, con las herramientas del módulo 10:

library(dplyr)
library(lubridate)
datos_heat <- sessions |>
mutate(
anio = year(salida),
mes = month(salida, label = TRUE)
) |>
group_by(anio, mes) |>
summarise(mediana = median(duracion_min), .groups = "drop")
  • month(salida, label = TRUE) — extrae el mes con su nombre (ene, feb…) en vez de un número, y como factor ordenado (para que enero vaya antes que febrero en el eje).
  • group_by(anio, mes) + summarise(mediana = median(...)) — la mediana de minutos fuera para cada combinación año-mes.

Ahora la versión sencilla del gráfico:

library(ggplot2)
ggplot(datos_heat, aes(x = mes, y = factor(anio), fill = mediana)) +
geom_tile() +
scale_fill_viridis_c() +
labs(title = "Mediana de minutos fuera por año y mes",
x = "Mes", y = "Año", fill = "Min.") +
theme_minimal()

Fíjate en y = factor(anio): como en los boxplots, convertimos el año en categoría para tener una fila por año. Ya tienes un mapa de calor funcional. Ahora vamos a pulirlo hasta dejarlo de calidad de publicación.

De lo sencillo a lo profesional, una capa a la vez

La versión final que viste en el módulo 1 parece intimidante, pero no es más que el gráfico de arriba con capas añadidas de a una. Vamos a construirla así, paso a paso, agregando una sola cosa cada vez y mirando el resultado. Parte siempre del gráfico sencillo de la sección anterior.

Paso 1 — Separar las celdas con un borde blanco

Pegadas unas a otras, las celdas se confunden. Un borde blanco las separa:

ggplot(datos_heat, aes(x = mes, y = factor(anio), fill = mediana)) +
geom_tile(color = "white", linewidth = 0.4) +
scale_fill_viridis_c() +
theme_minimal()

Lo único que cambió es geom_tile(color = "white", linewidth = 0.4). Cuidado con un detalle que confunde mucho: en geom_tile, fill es el relleno de la celda (el color del dato) y color es el borde. linewidth controla el grosor de ese borde. Mira el resultado: ahora se ve una cuadrícula limpia.

Paso 2 — Escribir el número dentro de cada celda

El color ya dice “mucho o poco”, pero un número exacto ayuda. Sumamos una capa de texto:

ggplot(datos_heat, aes(x = mes, y = factor(anio), fill = mediana)) +
geom_tile(color = "white", linewidth = 0.4) +
geom_text(aes(label = round(mediana)),
color = "white", size = 3.2, fontface = "bold") +
scale_fill_viridis_c() +
theme_minimal()

La capa nueva es geom_text(...):

  • aes(label = round(mediana)) — el texto de cada celda es el valor de mediana, redondeado con round() (para no mostrar decimales feos). Va dentro de aes() porque el texto depende de los datos.
  • color = "white", size = 3.2, fontface = "bold" — el color, tamaño y grosor del texto. Van fuera de aes() porque son fijos para todos.

Acabas de poner una capa de texto encima de las celdas. Recuerda: el orden importa, geom_text va después de geom_tile para quedar arriba.

Paso 3 — Títulos claros con labs()

Un buen gráfico se explica solo. Agregamos título, subtítulo y nombres de ejes:

# ...las dos capas anteriores, y además:
labs(
title = "¿Cuánto tiempo pasa Pépito fuera?",
subtitle = "Mediana de minutos fuera por año y mes (2011–2024)",
x = "Mes", y = "Año", fill = "Minutos"
) +

Lo nuevo frente a lo que ya conoces es subtitle: una línea secundaria bajo el título, ideal para aclarar qué se está midiendo.

Paso 4 — Agrandar todo el texto de un saque

El texto por defecto se ve pequeño. En vez de ajustarlo elemento por elemento, hay un atajo: pasarle un tamaño base al tema.

theme_minimal(base_size = 15)

base_size = 15 sube de golpe el tamaño de todo el texto del gráfico (ejes, leyenda, etc.). Es el truco más rápido para que un gráfico se vea mejor sin tocar nada más.

Paso 5 — Quitar la rejilla y agrandar la leyenda

En un mapa de calor, la rejilla de fondo sobra (las celdas ya son la cuadrícula). Y la barrita de color de la leyenda se agradece más alta. Para esos retoques finos usamos theme() (en minúscula):

theme_minimal(base_size = 15) +
theme(
panel.grid = element_blank(), # quita la rejilla de fondo
legend.key.height = unit(1.2, "cm") # barra de color más alta
)
  • panel.grid = element_blank()element_blank() significa “no dibujes esto”. Aquí, elimina la rejilla.
  • legend.key.height = unit(1.2, "cm") — hace más alta la barra de color (necesita unit() para indicar la medida).
💡

`theme_*()` vs `theme()` — no son lo mismo

  • theme_minimal(), theme_bw(), theme_classic() son temas completos prearmados: cambian todo el aspecto de una sola vez. Empieza siempre por uno de estos.
  • theme(...) (en minúscula, sin sufijo) es el ajuste fino: modifica elementos concretos (la rejilla, la leyenda, un título…).

El orden importa: primero el tema completo, luego los retoques con theme(), porque el segundo pisa al primero.

Paso 6 — Todo junto

Ahora sí, juntando los seis pasos obtienes la versión final. Cada línea ya la viste por separado; lo único extra son unos ajustes de tamaño y color de cada texto dentro de theme(), que son “más de lo mismo” del paso 5:

library(dplyr)
library(ggplot2)
library(lubridate)
datos_heat <- sessions |>
mutate(anio = year(salida),
mes = month(salida, label = TRUE)) |>
group_by(anio, mes) |>
summarise(mediana = median(duracion_min), .groups = "drop")
ggplot(datos_heat, aes(x = mes, y = factor(anio), fill = mediana)) +
geom_tile(color = "white", linewidth = 0.4) + # paso 1
geom_text(aes(label = round(mediana)), # paso 2
color = "white", size = 3.2, fontface = "bold") +
scale_fill_viridis_c(option = "viridis") +
labs( # paso 3
title = "¿Cuánto tiempo pasa Pépito fuera?",
subtitle = "Mediana de minutos fuera por año y mes (2011–2024)",
x = "Mes", y = "Año", fill = "Minutos"
) +
theme_minimal(base_size = 15) + # paso 4
theme( # paso 5 (+ retoques)
plot.title = element_text(size = 20, face = "bold"),
plot.subtitle = element_text(size = 14, color = "grey30",
margin = margin(b = 12)),
axis.title = element_text(size = 14, face = "bold"),
axis.text = element_text(size = 12),
legend.title = element_text(size = 13, face = "bold"),
legend.text = element_text(size = 11),
legend.key.height = unit(1.2, "cm"),
panel.grid = element_blank()
)

Los element_text(size = ..., face = ...) dentro de theme() solo ajustan el tamaño y el estilo de cada texto (el título más grande, el subtítulo gris, etc.). Son opcionales: el gráfico ya funcionaba en el paso 5. Aquí están solo para dejarlo de calidad de publicación.

Mapa de calor final: mediana de minutos que Pépito pasa fuera, por año y mes, de 2011 a 2024, con los valores escritos dentro de cada celda
Resultado esperado. ¡El gran final! 14 años de la vida de Pépito en un solo cuadro. La celda más amarilla es julio de 2015: 252 minutos de mediana fuera de casa.

Lee tu obra

¡Lo lograste! Tómate un momento para leer el gráfico que construiste. Busca la celda más amarilla: vas a encontrar julio de 2015 con una mediana de 252 minutos — más de 4 horas fuera de casa, el récord de aventura de Pépito. Mira también si los veranos (meses centrales) tienden a ser más claros que los inviernos. Catorce años de la vida de un gato, resumidos en un cuadro que hiciste tú, capa por capa.

Errores comunes en mapas de calor

⚠️

Error: celdas en blanco (huecos en la cuadrícula)

Si un año-mes no tiene datos (Pépito no registró salidas ese mes), no habrá celda y quedará un hueco. Es normal y honesto: refleja que no hay información. Si prefieres mostrarlo explícitamente, puedes completar las combinaciones faltantes con tidyr::complete(), pero para empezar, dejar el hueco está bien.

⚠️

Error: `scale_fill_viridis_c` no existe / no se aplica el color

Dos causas: usar scale_color_viridis_c() (con color) cuando mapeaste fill — deben coincidir: si pintas con fill, la escala es scale_fill_*. O tener una versión muy antigua de ggplot2. Solución: asegúrate de mapear fill = mediana y usar scale_fill_viridis_c(), y de tener ggplot2 actualizado con pak::pak("ggplot2").

¿Texto negro o blanco dentro de las celdas?

El texto blanco se lee bien sobre las celdas oscuras (moradas), pero puede perderse sobre las amarillas claras. Para gráficos de máxima calidad, algunos hacen que el color del texto cambie según el fondo. No es necesario ahora, pero si tus números claros no se leen, prueba color = "grey20" en el geom_text, o ajusta el tamaño.

Ejercicio

🧪

Ejercicio 11 — Construye el gran final

  1. Haz el mapa de calor de calentamiento (weekday × hour, color = conteo).
  2. Crea la tabla datos_heat (mediana de duracion_min por año y mes).
  3. Haz la versión sencilla del mapa de calor año × mes.
  4. Conviértela en la versión completa: agrega geom_text con los números, bordes blancos, subtítulo y los retoques de theme().
  5. Encuentra la celda más amarilla. ¿Qué mes y año fue el récord de Pépito?
Ver solución
library(dplyr)
library(lubridate)
library(ggplot2)
# 1. Calentamiento: weekday × hour
heat <- pepito_df |> count(weekday, hour)
ggplot(heat, aes(x = hour, y = weekday, fill = n)) +
geom_tile() +
scale_fill_viridis_c() +
scale_x_continuous(breaks = 0:23) +
labs(title = "¿Cuándo está activo Pépito?",
x = "Hora del día", y = "Día de la semana", fill = "Eventos") +
theme_minimal()
# 2. Resumen año × mes
datos_heat <- sessions |>
mutate(anio = year(salida),
mes = month(salida, label = TRUE)) |>
group_by(anio, mes) |>
summarise(mediana = median(duracion_min), .groups = "drop")
# 3 y 4. El gran final
ggplot(datos_heat, aes(x = mes, y = factor(anio), fill = mediana)) +
geom_tile(color = "white", linewidth = 0.4) +
geom_text(aes(label = round(mediana)),
color = "white", size = 3.2, fontface = "bold") +
scale_fill_viridis_c(option = "viridis") +
labs(title = "¿Cuánto tiempo pasa Pépito fuera?",
subtitle = "Mediana de minutos fuera por año y mes (2011–2024)",
x = "Mes", y = "Año", fill = "Minutos") +
theme_minimal(base_size = 15) +
theme(
plot.title = element_text(size = 20, face = "bold"),
plot.subtitle = element_text(size = 14, color = "grey30", margin = margin(b = 12)),
axis.title = element_text(size = 14, face = "bold"),
axis.text = element_text(size = 12),
legend.title = element_text(size = 13, face = "bold"),
legend.text = element_text(size = 11),
legend.key.height = unit(1.2, "cm"),
panel.grid = element_blank()
)

El récord: la celda más amarilla es julio de 2015, con una mediana de 252 minutos (más de 4 horas) fuera de casa. Si lo encontraste, ¡felicitaciones! Construiste, desde un JSON crudo hasta una visualización de calidad de publicación, tú solo. En el último módulo aprenderás a pulir y guardar tus gráficos para compartirlos.

12

Pulir y compartir: temas, etiquetas y ggsave

El objetivo del módulo

Ya sabes construir gráficos. Este último módulo es tu caja de herramientas para pulirlos y compartirlos: cambiar temas y colores, dividir un gráfico en pequeños múltiplos, ajustar la leyenda y los ejes, y —fundamental— guardar tu gráfico como imagen para un informe o una presentación. Cierra con un recetario y los errores más comunes de todo el curso.

Recordatorio: las capas que ya dominas

Antes de pulir, recordemos el esqueleto. Cualquier gráfico de este curso es esto, con más o menos capas:

ggplot(datos, aes(x = ..., y = ..., fill/color = ...)) + # datos + mapeo
geom_algo() + # la forma
scale_...() + # escalas (opcional)
labs(...) + # etiquetas
theme_...() # apariencia

Pulir un gráfico es, casi siempre, trabajar en las tres últimas capas.

Temas: cambia el aspecto de una sola vez

Un tema completo cambia todo el look del gráfico. Prueba varios sobre el mismo gráfico y quédate con el que más te guste:

library(ggplot2)
base <- ggplot(pepito_df, aes(x = hour, fill = way)) +
geom_bar(position = "dodge")
base + theme_minimal() # limpio, fondo blanco, rejilla suave (el del curso)
base + theme_bw() # blanco y negro, con marco
base + theme_classic() # estilo clásico, sin rejilla
base + theme_void() # sin ejes ni fondo (útil para mapas de calor o arte)

Guarda tu gráfico base en una variable

Como ves arriba, puedes guardar un gráfico en una variable (base <- ggplot(...) + ...) y luego sumarle capas sobre la marcha (base + theme_bw()). Es comodísimo para probar variantes sin reescribir todo. Un gráfico de ggplot2 es un objeto de R como cualquier otro.

Colores a tu gusto

Para elegir los colores de los grupos, usa una escala scale_..._manual() (colores que tú eliges) o una paleta prearmada:

# Colores elegidos a mano
base +
scale_fill_manual(values = c("in" = "#2C7FB8", "out" = "#F03B20")) +
theme_minimal()
# Una paleta prearmada (ColorBrewer)
base +
scale_fill_brewer(palette = "Set2") +
theme_minimal()
  • scale_fill_manual(values = c(...)) — asignas un color a cada categoría. Puedes usar nombres ("red") o códigos hexadecimales ("#2C7FB8").
  • scale_fill_brewer(palette = "Set2") — usa una paleta diseñada por expertos en color. Hay muchas ("Set1", "Dark2", "Paired"…).
⚠️

`fill` o `color`: que la escala coincida

Si mapeaste el grupo con fill, usa scale_fill_*. Si lo mapeaste con color (líneas, puntos), usa scale_color_*. Mezclarlos es un error silencioso muy común: cambias la escala y “no pasa nada”, porque estás tocando una propiedad que no usaste.

Pequeños múltiplos con facet_wrap()

Una de las herramientas más potentes de ggplot2: facet_wrap() parte un gráfico en varios mini-gráficos, uno por cada valor de una variable. Por ejemplo, la actividad por hora, un panel por día de la semana:

ggplot(pepito_df, aes(x = hour)) +
geom_bar(fill = "steelblue") +
facet_wrap(~ weekday) +
scale_x_continuous(breaks = seq(0, 24, 6)) +
labs(title = "Actividad por hora, según el día de la semana",
x = "Hora", y = "Eventos") +
theme_minimal()
  • facet_wrap(~ weekday) — crea un panel por cada día. La ~ (virgulilla) se lee “según”: divide según weekday.

Comparar paneles lado a lado suele revelar diferencias que un solo gráfico esconde. Es ideal para “¿es distinto el patrón entre grupos?”.

`facet_wrap` vs `facet_grid`

  • facet_wrap(~ var) — paneles por una variable, acomodados en una grilla automática.
  • facet_grid(fila ~ columna) — paneles en una matriz por dos variables (una en filas, otra en columnas).

Empieza con facet_wrap; pasa a facet_grid cuando quieras cruzar dos variables.

Ajustes finos frecuentes

Dos retoques que vas a necesitar seguido, ambos con theme():

base +
theme_minimal() +
theme(
legend.position = "top", # mueve la leyenda arriba
axis.text.x = element_text(angle = 45, hjust = 1) # rota las etiquetas del eje X
)
  • legend.position"top", "bottom", "left", "right" o "none" (para ocultarla).
  • axis.text.x = element_text(angle = 45, hjust = 1) — gira las etiquetas del eje X 45°, útil cuando son largas y se enciman. hjust = 1 las alinea bien.

Lo más importante: guardar tu gráfico con ggsave()

Un gráfico que solo vive en la pantalla de R no sirve para un informe. ggsave() lo guarda como archivo de imagen:

mi_grafico <- ggplot(pepito_df, aes(x = hour, fill = way)) +
geom_bar(position = "dodge") +
theme_minimal()
ggsave("pepito_horas.png", plot = mi_grafico,
width = 8, height = 5, dpi = 300)
  • "pepito_horas.png" — el nombre del archivo. La extensión decide el formato: .png (imagen normal), .pdf (vectorial, escala sin pixelarse), .jpg, .svg
  • plot = mi_grafico — qué gráfico guardar. Si lo omites, ggsave() guarda el último gráfico que dibujaste.
  • width y height — el tamaño en pulgadas.
  • dpi = 300 — la resolución; 300 es calidad de impresión.
⚠️

Errores típicos al guardar

  • Error: Cannot find the file / could not open — la carpeta de destino no existe. ggsave() no crea carpetas; créala antes o guarda en una que exista.
  • La imagen sale en blanco — guardaste antes de dibujar, o pasaste el objeto equivocado en plot =. Asegúrate de que mi_grafico contenga el gráfico.
  • El texto sale gigante o minúsculo — el tamaño del texto depende de width/height. Si las letras se ven mal, ajusta esas dimensiones (un gráfico más grande “achica” el texto relativo).

PNG para la web, PDF para imprimir

Para una presentación o la web, usa .png con dpi = 300. Para un documento que se va a imprimir o que necesita verse nítido a cualquier tamaño, usa .pdf (es vectorial: nunca se pixela). Cambiar de formato es tan simple como cambiar la extensión del nombre.

Recetario: ¿qué geom para qué pregunta?

Tu preguntaGeomVisto en
¿Cuántos hay de cada categoría?geom_bar()Módulo 6
Ya tengo los totales, dibújalosgeom_col()Módulo 6
¿Cómo cambia algo en el tiempo?geom_line(), geom_freqpoly()Módulo 7
¿Cómo se distribuye una variable?geom_histogram(), geom_density()Módulo 8
¿Cómo comparo distribuciones entre grupos?geom_boxplot()Módulo 9
¿Cuál es la tendencia entre tanto punto?geom_smooth()Módulo 10
¿Dónde se concentra algo en 2 dimensiones?geom_tile()Módulo 11
¿Quiero escribir valores en el gráfico?geom_text()Módulo 11
¿Quiero un panel por grupo?facet_wrap()Módulo 12

Los errores que más se repiten (resumen del curso)

⚠️

Los 7 tropiezos clásicos de ggplot2

  1. El + al principio de la línea en vez de al final. → Va al final.
  2. Color fijo dentro de aes() (aes(fill = "blue")). → Color fijo va fuera de aes().
  3. Mezclar |> y +.|> para datos, + para capas de ggplot2.
  4. Olvidar factor() en boxplots y mapas de calor con ejes numéricos. → Una sola caja gigante es la señal.
  5. scale_fill_* vs scale_color_* que no coinciden con lo que mapeaste.
  6. Fechas que son texto. → Conviértelas con lubridate (módulo 4); compruébalo con glimpse().
  7. Usar lead()/lag() sin arrange() antes. → Ordena siempre primero.

Hasta aquí llegamos (¡felicitaciones!)

Empezaste sin saber qué era un JSON y terminaste construyendo un mapa de calor de calidad de publicación con 14 años de datos. Por el camino aprendiste a:

  • Leer un JSON desde la web y convertirlo en un data frame.
  • Transformar fechas de texto en fechas reales y crear columnas útiles.
  • Entender la gramática de ggplot2 y construir gráficos por capas.
  • Elegir el gráfico correcto según la pregunta.
  • Pulir, colorear, dividir en paneles y guardar tus gráficos.

Lo más valioso que te llevas no es una lista de funciones, sino una forma de pensar: datos → mapeo → geometría → pulido. Con eso puedes graficar cualquier dataset, no solo el de Pépito.

¿Hacia dónde seguir?

  • Aplica lo aprendido a tus propios datos: el flujo es siempre el mismo.
  • Explora la galería oficial de ggplot2 y la cheat sheet de RStudio para descubrir más geoms.
  • Mira patchwork (para combinar varios gráficos en una figura) y plotly/ggiraph (para gráficos interactivos).
  • Revisa los otros cursos de Hazla con Datos (al pie de la página).
🧪

Ejercicio 12 — Tu propia visualización

El ejercicio final no tiene solución única. Toma una de las tres preguntas que anotaste en el módulo 1 y respóndela con un gráfico, de principio a fin:

  1. Elige la pregunta y decide qué geom le corresponde (usa el recetario).
  2. Prepara los datos que necesites (filter, mutate, group_by + summarise…).
  3. Construye el gráfico capa por capa.
  4. Púlelo: título, colores, tema.
  5. Guárdalo con ggsave() y compártelo (¡con la comunidad de Hazla con Datos, si quieres!).

Si llegaste hasta aquí y puedes hacer esto sin copiar, ya sabes ggplot2. 🎉

Ver un ejemplo resuelto

Pregunta de ejemplo: “¿A qué hora suele volver Pépito a casa?”

library(dplyr)
library(ggplot2)
# 1–2. Preparar: solo las entradas, con su hora
entradas <- pepito_df |>
filter(way == "in") |>
mutate(hora = hour(datetime_fr))
# 3–4. Graficar y pulir
g <- ggplot(entradas, aes(x = hora)) +
geom_bar(fill = "#2C7FB8") +
scale_x_continuous(breaks = 0:23) +
labs(title = "¿A qué hora vuelve Pépito a casa?",
x = "Hora del día", y = "Número de entradas") +
theme_minimal()
g
# 5. Guardar
ggsave("pepito_regreso.png", plot = g, width = 8, height = 5, dpi = 300)

Lo importante no es este gráfico en particular, sino que recorriste el flujo completo —preparar, graficar, pulir, guardar— por tu cuenta. Esa es exactamente la habilidad que te llevas del curso.

Siguiente 1. Bienvenida