Análisis de Demanda de EcoBici
Una introducción práctica a DuckDB y dbplyr en R
1 ¿Qué vamos a aprender?
En este documento analizamos los datos abiertos de EcoBici, el sistema de bicicletas compartidas de la Ciudad de México. Pero el objetivo no es solo entender cómo se usan las bicis — es aprender a trabajar con datos grandes de manera eficiente.
El archivo con el que trabajamos tiene cientos de miles de filas. Cargarlo completo a la memoria de R sería lento e impráctico. Por eso usamos DuckDB, un motor de base de datos que puede leer y procesar archivos CSV directamente desde el disco, sin necesidad de importarlos.
| Paquete | ¿Para qué sirve? |
|---|---|
duckdb |
Motor de base de datos en memoria, ultrarrápido para archivos grandes |
dbplyr |
Traduce código dplyr a SQL para que corra dentro de DuckDB |
tidyverse |
Manipulación de datos (dplyr, tidyr) y visualización (ggplot2) |
scales |
Formatos bonitos para los ejes de las gráficas (ej: 1,000 en vez de 1000) |
2 Conexión y carga de datos
2.1 ¿Cómo funciona DuckDB?
Normalmente, cuando usamos read_csv() en R, el archivo completo se carga en la RAM de tu computadora. Con archivos grandes, eso puede ser lento o incluso imposible.
DuckDB funciona diferente: actúa como una base de datos que vive dentro de tu sesión de R. En vez de cargar el archivo, le decimos a DuckDB “aquí está el archivo, léelo cuando lo necesites”. Nosotros escribimos código en R con dplyr, DuckDB lo convierte a SQL, hace los cálculos, y solo nos devuelve el resultado final — que sí es pequeño y cabe en RAM.
2.2 Registrar el CSV como una vista
En lugar de importar el CSV a R, lo registramos como una vista en DuckDB. Una vista es como un “atajo” que apunta al archivo — DuckDB sabe dónde está y cómo leerlo, pero no lo carga hasta que lo necesita.
[1] 0
glue::glue()?
glue() es una función para construir cadenas de texto de forma legible. Dentro de las llaves {} puedes meter cualquier variable de R y la sustituye automáticamente. Es más claro que pegar strings con paste0().
2.3 Verificar la carga
Antes de hacer cualquier análisis, siempre vale la pena echar un vistazo a los datos para confirmar que se cargaron bien.
# Source: table<viajes_raw> [?? x 9]
# Database: DuckDB 1.4.4 [unknown@Linux 6.5.0-1024-aws:R 4.5.2/:memory:]
Genero_Usuario Edad_Usuario Bici Ciclo_Estacion_Retiro Fecha_Retiro
<chr> <chr> <dbl> <chr> <date>
1 M 30 4933404 059 2025-12-31
2 M 27 4162376 180 2025-12-31
3 M 29 3749272 014 2025-12-31
4 M 44 5680772 014 2025-12-31
5 M 22 5128459 031 2025-12-31
6 M 67 2706091 015 2025-12-31
7 M 33 6206304 041 2025-12-31
8 M 34 7702525 538 2025-12-31
9 M 28 7849797 538 2025-12-31
10 M 32 4707477 328 2025-12-31
# ℹ more rows
# ℹ 4 more variables: Hora_Retiro <drtn>, Ciclo_EstacionArribo <chr>,
# Fecha_Arribo <date>, Hora_Arribo <drtn>
Cuando escribes tbl(con, "viajes_raw"), R no descarga los datos. Solo crea un objeto que sabe cómo pedirle los datos a DuckDB cuando sea necesario. Esto se llama evaluación lazy (perezosa). Los datos solo viajan de DuckDB a R cuando llamas collect().
3 Análisis por hora del día
3.1 ¿A qué horas se usan más las bicis?
La primera pregunta que queremos responder es simple: ¿cuál es la distribución de retiros a lo largo del día?
df_por_hora <- tbl(con, "viajes_raw") %>%
mutate(
# CAST(Hora_Retiro AS TIME) convierte el texto "HH:MM:SS" al tipo TIME
# que DuckDB puede entender. Usamos sql() para "blindar" la expresión:
# le decimos a R "no intentes interpretar esto, mándalo tal cual a DuckDB"
hora = hour(sql("CAST(Hora_Retiro AS TIME)"))
) %>%
group_by(hora) %>%
summarise(total_retiros = n(), .groups = "drop") %>%
arrange(hora) %>%
collect() # <- ¡Aquí es donde los datos "aterrizan" en R!collect() al final
Mientras escribes código con tbl() y dplyr, estás construyendo una consulta SQL que aún no se ha ejecutado. DuckDB espera pacientemente. Solo cuando llamas collect() se ejecuta todo el trabajo y el resultado llega a R.
Esto significa que debes hacer todo lo posible dentro de DuckDB (filtros, agrupaciones, cálculos) y solo traer a R el resultado final, que es pequeño.
3.2 Gráfica: Curva de demanda agregada
ggplot(df_por_hora, aes(x = hora, y = total_retiros)) +
# geom_area rellena el área bajo la curva
geom_area(fill = "#1f77b4", alpha = 0.2) +
# geom_line dibuja la línea principal
geom_line(color = "#1f77b4", linewidth = 1.2) +
scale_x_continuous(breaks = seq(0, 23, by = 2)) +
# label_comma() formatea los números con coma de miles
scale_y_continuous(labels = label_comma()) +
labs(
title = "Curva de Demanda Agregada",
subtitle = paste("Total de viajes:", comma(sum(df_por_hora$total_retiros))),
x = "Hora del día",
y = "Número de retiros",
caption = "Fuente: EcoBici | Motor: DuckDB"
) +
theme_minimal()4 Análisis por día de la semana
4.1 ¿El patrón cambia entre semana y fin de semana?
Un paso más: ahora queremos ver si el comportamiento varía según el día. La hipótesis es que los días laborales tendrán dos picos (mañana y tarde, por commuters) mientras que el fin de semana tendrá un pico único al mediodía (ocio).
df_por_dia_hora <- tbl(con, "viajes_raw") %>%
mutate(
hora = hour(sql("CAST(Hora_Retiro AS TIME)")),
# dayofweek() es una función de DuckDB que devuelve:
# 0 = Domingo, 1 = Lunes, ..., 6 = Sábado
dia_num = dayofweek(sql("CAST(Fecha_Retiro AS DATE)"))
) %>%
group_by(dia_num, hora) %>%
summarise(total_retiros = n(), .groups = "drop") %>%
collect() %>%
# Una vez en R, convertimos el número a una etiqueta legible con factor()
mutate(
dia_semana = factor(
dia_num,
levels = 0:6,
labels = c("Dom", "Lun", "Mar", "Mié", "Jue", "Vie", "Sáb")
)
)factor en R y no en DuckDB?
Podríamos hacer esta conversión dentro de DuckDB con un CASE WHEN, pero es más trabajoso. La regla práctica es: deja los cálculos pesados a DuckDB (filtros, agrupaciones, sumas de millones de filas) y las transformaciones de presentación a R (etiquetas, reordenar niveles, formatear).
4.2 Gráfica: Facetas por día
ggplot(df_por_dia_hora, aes(x = hora, y = total_retiros, color = dia_semana)) +
geom_line(linewidth = 0.8, show.legend = FALSE) +
# facet_wrap divide la gráfica en un panel por día
facet_wrap(~dia_semana, ncol = 4) +
scale_x_continuous(breaks = c(0, 8, 14, 20)) +
scale_y_continuous(labels = label_comma()) +
# scale_color_viridis_d usa una paleta accesible para daltónicos
scale_color_viridis_d(option = "mako") +
labs(
title = "Patrones de Movilidad por Día de la Semana",
subtitle = "Días laborales: pico doble (commuters) | Fin de semana: pico único (ocio)",
x = "Hora",
y = "Viajes"
) +
theme_light() +
theme(
strip.background = element_rect(fill = "#404040"),
strip.text = element_text(color = "white", face = "bold")
)5 Balance por estación: ¿quién se queda sin bicis?
5.1 El concepto de “balance”
Hasta ahora solo miramos retiros. Pero para operar el sistema, lo que importa es el balance de cada estación:
\[\text{Balance} = \text{Arribos} - \text{Retiros}\]
- Si el balance es positivo → llegan más bicis de las que salen → la estación se llena
- Si el balance es negativo → salen más bicis de las que llegan → la estación se vacía
Las estaciones que se vacían en hora pico son las más críticas: hay usuarios que llegan y no encuentran bicicleta.
5.2 Calculando retiros y arribos por separado
Como cada viaje tiene una estación de salida y una de llegada, necesitamos contarlos por separado y luego unirlos.
# Contamos cuántos retiros hubo por estación y hora
retiros_x_estacion <- tbl(con, "viajes_raw") %>%
mutate(hora = hour(sql("CAST(Hora_Retiro AS TIME)"))) %>%
group_by(estacion = Ciclo_Estacion_Retiro, hora) %>%
summarise(n_retiros = n(), .groups = "drop")
# Contamos cuántos arribos hubo por estación y hora
arribos_x_estacion <- tbl(con, "viajes_raw") %>%
mutate(hora = hour(sql("CAST(Hora_Arribo AS TIME)"))) %>%
group_by(estacion = Ciclo_EstacionArribo, hora) %>%
summarise(n_arribos = n(), .groups = "drop")5.3 Uniendo las tablas con full_join
# full_join mantiene todas las combinaciones estación/hora,
# aunque no haya retiros O no haya arribos en alguna de ellas
df_balance <- full_join(retiros_x_estacion, arribos_x_estacion,
by = c("estacion", "hora")) %>%
mutate(
# coalesce(x, 0) reemplaza NA por 0
# Un NA aquí significa "no hubo ningún movimiento", que equivale a 0
n_retiros = coalesce(n_retiros, 0),
n_arribos = coalesce(n_arribos, 0),
balance = n_arribos - n_retiros
) %>%
collect()full_join vs inner_join vs left_join
inner_join: solo conserva filas que aparecen en ambas tablasleft_join: conserva todas las filas de la tabla izquierda, aunque no tengan parfull_join: conserva todas las filas de ambas tablas → es el correcto aquí, porque una estación puede tener retiros pero cero arribos en cierta hora (o viceversa)
5.4 Las 10 estaciones más críticas en hora pico
# Filtramos solo la hora pico matutina (7 a 10 am)
# y calculamos la pérdida acumulada de cada estación en ese rango
top_vaciadas <- df_balance %>%
filter(hora >= 7, hora <= 10) %>%
group_by(estacion) %>%
summarise(perdida_neta = sum(balance)) %>%
# slice_min selecciona las n filas con el valor MÁS BAJO
# Como el balance negativo = pérdida, las más negativas son las críticas
slice_min(perdida_neta, n = 10)
top_vaciadas# A tibble: 10 × 2
estacion perdida_neta
<chr> <dbl>
1 273-274 -2392
2 271-272 -2339
3 208 -1583
4 548 -1381
5 547 -1342
6 158-159 -928
7 495 -901
8 465 -843
9 492 -819
10 266-267 -768
5.5 Gráfica: flujo hora a hora de las estaciones críticas
df_balance %>%
filter(estacion %in% top_vaciadas$estacion) %>%
ggplot(aes(x = hora, y = balance, fill = balance > 0)) +
geom_col() +
facet_wrap(~paste("Est.", estacion), scales = "free_y") +
scale_fill_manual(
values = c("TRUE" = "#31a354", "FALSE" = "#de2d26"),
labels = c("TRUE" = "Se llena", "FALSE" = "Se vacía"),
name = NULL
) +
labs(
title = "Top 10 Estaciones Críticas: Flujo de Inventario",
subtitle = "Las que más bicis pierden entre las 7:00 y 10:00 am",
x = "Hora del día",
y = "Balance (Entradas − Salidas)",
caption = "Valores negativos = requiere reabastecimiento urgente"
) +
theme_minimal() +
theme(legend.position = "bottom")6 Zoom: Estación 333 día a día
6.1 ¿Por qué hacer zoom en una estación?
El análisis agregado nos da el panorama general. Pero para tomar decisiones operativas — ¿cuándo mandar el camión de reabastecimiento? — necesitamos ver el patrón típico por día de la semana de cada estación de interés.
Hagámoslo con la estación 333.
6.2 Extraer solo los viajes de la estación 333
# En DuckDB, el filtro corre ANTES de traer datos a R
# Así solo descargamos los viajes relevantes, no el dataset completo
retiros_333 <- tbl(con, "viajes_raw") %>%
filter(Ciclo_Estacion_Retiro == "333") %>%
mutate(
hora = hour(sql("CAST(Hora_Retiro AS TIME)")),
fecha = sql("CAST(Fecha_Retiro AS DATE)")
) %>%
group_by(fecha, hora) %>%
summarise(n_retiros = n(), .groups = "drop")
arribos_333 <- tbl(con, "viajes_raw") %>%
filter(Ciclo_EstacionArribo == "333") %>%
mutate(
hora = hour(sql("CAST(Hora_Arribo AS TIME)")),
fecha = sql("CAST(Fecha_Arribo AS DATE)")
) %>%
group_by(fecha, hora) %>%
summarise(n_arribos = n(), .groups = "drop")6.3 Calcular el balance promedio por día de la semana
df_balance_333 <- full_join(retiros_333, arribos_333, by = c("fecha", "hora")) %>%
collect() %>%
mutate(
n_retiros = replace_na(n_retiros, 0),
n_arribos = replace_na(n_arribos, 0),
balance = n_arribos - n_retiros,
# lubridate::wday convierte una fecha al nombre del día
# week_start = 1 indica que la semana empieza el lunes (convención europea/ISO)
# label = TRUE devuelve el nombre, abbr = FALSE el nombre completo
dia_semana = lubridate::wday(fecha, label = TRUE, abbr = FALSE, week_start = 1)
) %>%
# Agrupamos todos los lunes, todos los martes, etc.
# y calculamos el balance PROMEDIO para cada combinación día/hora
group_by(dia_semana, hora) %>%
summarise(balance_promedio = mean(balance), .groups = "drop")replace_na() aquí y coalesce() antes?
Son equivalentes pero viven en mundos diferentes:
coalesce()es una función de DuckDB/SQL que funciona dentro detbl(), antes decollect()replace_na()es una función de tidyr/R que solo funciona después decollect()
Usamos cada una en su contexto correcto.
6.4 Gráfica final: el pulso semanal de la estación 333
ggplot(df_balance_333, aes(x = hora, y = balance_promedio)) +
# Línea de referencia en cero para facilitar la lectura
geom_hline(yintercept = 0, linetype = "dashed", alpha = 0.5) +
# El área sombreada refuerza visualmente el signo del balance
geom_area(aes(fill = balance_promedio > 0), alpha = 0.4, show.legend = FALSE) +
geom_line(linewidth = 0.8) +
facet_wrap(~dia_semana, ncol = 4) +
scale_fill_manual(values = c("TRUE" = "#00A699", "FALSE" = "#FF5A5F")) +
scale_x_continuous(breaks = c(0, 8, 14, 20)) +
labs(
title = "Balance Neto Promedio: Estación 333",
subtitle = "Verde = gana bicis | Rojo = pierde bicis (requiere reabastecimiento)",
x = "Hora del día",
y = "Balance Promedio (Entradas − Salidas)",
caption = "Análisis por día de la semana | Fuente: EcoBici"
) +
theme_minimal() +
theme(
panel.grid.minor = element_blank(),
strip.text = element_text(face = "bold", size = 11)
)7 Conclusiones y siguientes pasos
7.1 Lo que aprendimos hoy
En este análisis cubrimos tres niveles de análisis:
- Ciudad completa → la demanda tiene dos picos en días laborales y uno en fin de semana
- Estaciones críticas → podemos identificar automáticamente cuáles se vacían en hora pico
- Estación individual → podemos calcular el patrón típico para informar decisiones de reabastecimiento
Y de paso aprendimos a:
- Conectar R a DuckDB para procesar archivos grandes sin saturar la RAM
- Usar
dbplyrpara escribirdplyrque corre como SQL - Aplicar
collect()solo al final del pipeline - Usar
full_joinpara combinar tablas sin perder información
7.2 ¿Qué podrías explorar tú?
- Cambiar
"333"por cualquier otra estación para obtener su análisis - Agregar información geográfica (lat/lon de las estaciones) para hacer un mapa de calor
- Comparar el mismo mes entre años para detectar tendencias de largo plazo
- Calcular el tiempo promedio de viaje por origen-destino
La conexión a DuckDB se cierra automáticamente al final del documento con dbDisconnect(con, shutdown = TRUE). Este chunk está oculto (include: false) porque es una limpieza interna, no algo que el lector necesite ver.