Análisis de Demanda de EcoBici

Una introducción práctica a DuckDB y dbplyr en R

Autor/a

Manuel Toral

Fecha de publicación

19 de febrero de 2026

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.

NotaHerramientas que usaremos
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.

# dbConnect(duckdb()) abre una conexión a DuckDB en memoria
# Piénsalo como "encender" la base de datos
con <- dbConnect(duckdb())

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.

ARCHIVO <- "2026-01.csv"

# CREATE VIEW crea el atajo; read_csv_auto detecta tipos de columnas automáticamente
dbExecute(
  con,
  glue::glue("CREATE VIEW viajes_raw AS SELECT * FROM read_csv_auto('{ARCHIVO}')")
)
[1] 0
Tip¿Qué es 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.

# tbl() crea una referencia "perezosa" a la tabla en DuckDB
# Al imprimirla, DuckDB ejecuta un SELECT limitado para mostrarnos un preview
tbl(con, "viajes_raw")
# 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>
Nota¿Qué es una referencia “perezosa” (lazy)?

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!
ImportanteLa regla de oro: 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()

La curva muestra dos picos claros: uno matutino (~8 am) y otro vespertino (~6 pm), típicos de una ciudad con commuters.

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")
    )
  )
Tip¿Por qué convertir a 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")
  )

La diferencia entre días laborales y fin de semana es clara. Los lunes a viernes muestran el patrón bimodal del commuter; sábado y domingo tienen un perfil más suave y tardío.

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()
Notafull_join vs inner_join vs left_join
  • inner_join: solo conserva filas que aparecen en ambas tablas
  • left_join: conserva todas las filas de la tabla izquierda, aunque no tengan par
  • full_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")

Las barras rojas muestran las horas donde la demanda supera la oferta. Los patrones son consistentes: las mañanas vacían estas estaciones y las tardes las reponen.

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")
Tip¿Por qué replace_na() aquí y coalesce() antes?

Son equivalentes pero viven en mundos diferentes:

  • coalesce() es una función de DuckDB/SQL que funciona dentro de tbl(), antes de collect()
  • replace_na() es una función de tidyr/R que solo funciona después de collect()

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)
  )

El patrón revela que la estación 333 pierde bicis en la mañana (rojo) y las recupera en la tarde (verde) — comportamiento típico de una estación en zona de origen de viajes.

7 Conclusiones y siguientes pasos

7.1 Lo que aprendimos hoy

En este análisis cubrimos tres niveles de análisis:

  1. Ciudad completa → la demanda tiene dos picos en días laborales y uno en fin de semana
  2. Estaciones críticas → podemos identificar automáticamente cuáles se vacían en hora pico
  3. 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 dbplyr para escribir dplyr que corre como SQL
  • Aplicar collect() solo al final del pipeline
  • Usar full_join para 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

NotaNota técnica

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.