Manejo de datos con tidyverse

Minicurso de R · Módulo 2

R
datos
tidyverse
dplyr
tidyr
Importar, limpiar, transformar, unir y reestructurar tablas de datos; fechas, factores y datos faltantes con dplyr, tidyr, lubridate y forcats.
Author

Mauricio Romero

Published

September 25, 2026

Duración estimada: 3 horas · Nivel: principiante–intermedio · Prerrequisitos: Módulos 0 y 1.

Objetivos de aprendizaje

Al terminar este cuaderno podrás:

  1. Importar y exportar datos en CSV y otros formatos, sin depender de rutas absolutas.
  2. Explicar qué es un conjunto de datos ordenado (tidy) y reestructurar tablas entre formato ancho y largo.
  3. Seleccionar, filtrar, crear variables y resumir por grupos con dplyr.
  4. Unir tablas y combinar varios archivos en uno solo.
  5. Manejar fechas, texto y factores.
  6. Diagnosticar y tratar datos faltantes.
library(tidyverse)   # dplyr, tidyr, readr, stringr, forcats, ggplot2...
library(lubridate)   # fechas
library(palmerpenguins)
set.seed(2026)

1. Importar y exportar

Los datos vienen casi siempre en archivos de texto separados por comas (CSV). readr::read_csv() es rápido, adivina el tipo de cada columna y devuelve un tibble (un data.frame con mejor impresión).

Como aquí no tenemos un archivo, primero escribimos uno temporal y luego lo leemos. Es el mismo procedimiento que con tus propios archivos:

ruta <- tempfile(fileext = ".csv")

muestras <- tibble(
  estacion = c("E01", "E02", "E03", "E04"),
  fecha    = as.Date(c("2025-03-01", "2025-03-01", "2025-03-02", "2025-03-02")),
  temp_c   = c(26.4, 27.1, NA, 25.8),
  salinidad = c(34.9, 35.2, 35.0, 34.7)
)

write_csv(muestras, ruta)        # exportar
leidos <- read_csv(ruta)         # importar
leidos
# A tibble: 4 × 4
  estacion fecha      temp_c salinidad
  <chr>    <date>      <dbl>     <dbl>
1 E01      2025-03-01   26.4      34.9
2 E02      2025-03-01   27.1      35.2
3 E03      2025-03-02   NA        35  
4 E04      2025-03-02   25.8      34.7

Observa que read_csv() reconoció fecha como fecha (<date>) y temp_c como número. Si necesitas controlar los tipos usa col_types:

read_csv(ruta, col_types = cols(estacion = col_character(),
                                fecha = col_date("%Y-%m-%d"),
                                .default = col_double()))
# A tibble: 4 × 4
  estacion fecha      temp_c salinidad
  <chr>    <date>      <dbl>     <dbl>
1 E01      2025-03-01   26.4      34.9
2 E02      2025-03-01   27.1      35.2
3 E03      2025-03-02   NA        35  
4 E04      2025-03-02   25.8      34.7
TipConsejos para archivos reales
  • Guarda los datos en una carpeta datos/ dentro del proyecto y léelos con rutas relativas (read_csv("datos/muestras.csv")), nunca con rutas absolutas como ~/Google Drive/....
  • En archivos con coma decimal y punto y coma como separador usa read_csv2(). Si hay tildes mal leídas, indica locale = locale(encoding = "latin1").
  • Para Excel usa readxl::read_excel(), para SPSS/Stata haven::read_sav() y para archivos enormes data.table::fread() o arrow.
  • Para guardar un objeto de R tal cual, usa saveRDS() / readRDS().

2. Teoría: datos ordenados (tidy)

Un conjunto de datos es ordenado cuando cumple tres reglas (Wickham, 2014):

  1. Cada variable es una columna.
  2. Cada observación es una fila.
  3. Cada valor ocupa una celda.

Muchas tablas hechas para leer (con años en columnas, por ejemplo) violan la regla 1. Se llaman de formato ancho. Para analizar y graficar con R conviene el formato largo.

ancho <- tibble(
  pais = c("Colombia", "Perú", "Chile"),
  `2020` = c(50.9, 33.0, 19.4),
  `2021` = c(51.5, 33.4, 19.5),
  `2022` = c(52.1, 33.7, 19.6)
)
ancho
# A tibble: 3 × 4
  pais     `2020` `2021` `2022`
  <chr>     <dbl>  <dbl>  <dbl>
1 Colombia   50.9   51.5   52.1
2 Perú       33     33.4   33.7
3 Chile      19.4   19.5   19.6

pivot_longer() convierte columnas en filas y pivot_wider() hace lo contrario:

largo <- ancho |>
  pivot_longer(cols = -pais, names_to = "anio", values_to = "poblacion_millones") |>
  mutate(anio = as.integer(anio))
largo
# A tibble: 9 × 3
  pais      anio poblacion_millones
  <chr>    <int>              <dbl>
1 Colombia  2020               50.9
2 Colombia  2021               51.5
3 Colombia  2022               52.1
4 Perú      2020               33  
5 Perú      2021               33.4
6 Perú      2022               33.7
7 Chile     2020               19.4
8 Chile     2021               19.5
9 Chile     2022               19.6
largo |>
  pivot_wider(names_from = pais, values_from = poblacion_millones)
# A tibble: 3 × 4
   anio Colombia  Perú Chile
  <int>    <dbl> <dbl> <dbl>
1  2020     50.9  33    19.4
2  2021     51.5  33.4  19.5
3  2022     52.1  33.7  19.6
ggplot(largo, aes(anio, poblacion_millones, colour = pais)) +
  geom_line() + geom_point() +
  scale_x_continuous(breaks = 2020:2022) +
  scale_colour_brewer(palette = "Set2", name = NULL) +
  labs(x = "Año", y = "Población (millones)") +
  theme_minimal(base_size = 12)
Figure 1: El formato largo se mapea directamente a la estética de ggplot2: una columna para el eje x, una para el eje y y otra para el color.

3. Los verbos de dplyr

dplyr resuelve la mayoría de las transformaciones con cinco verbos, que se encadenan con la tubería |> (“y luego”):

Verbo Qué hace
select() elige columnas
filter() elige filas según una condición
mutate() crea o modifica columnas
arrange() ordena filas
summarise() con group_by() resume por grupos

Usamos la tabla de pingüinos de Palmer (344 pingüinos de tres especies en la Antártida):

glimpse(penguins)
Rows: 344
Columns: 8
$ species           <fct> Adelie, Adelie, Adelie, Adelie, Adelie, Adelie, Adel…
$ island            <fct> Torgersen, Torgersen, Torgersen, Torgersen, Torgerse…
$ bill_length_mm    <dbl> 39.1, 39.5, 40.3, NA, 36.7, 39.3, 38.9, 39.2, 34.1, …
$ bill_depth_mm     <dbl> 18.7, 17.4, 18.0, NA, 19.3, 20.6, 17.8, 19.6, 18.1, …
$ flipper_length_mm <int> 181, 186, 195, NA, 193, 190, 181, 195, 193, 190, 186…
$ body_mass_g       <int> 3750, 3800, 3250, NA, 3450, 3650, 3625, 4675, 3475, …
$ sex               <fct> male, female, female, NA, female, male, female, male…
$ year              <int> 2007, 2007, 2007, 2007, 2007, 2007, 2007, 2007, 2007…
penguins |>
  select(especie = species, isla = island, masa_g = body_mass_g, sexo = sex) |>
  filter(!is.na(masa_g), sexo == "female") |>
  mutate(masa_kg = masa_g / 1000) |>
  arrange(desc(masa_kg)) |>
  head(5)
# A tibble: 5 × 5
  especie isla   masa_g sexo   masa_kg
  <fct>   <fct>   <int> <fct>    <dbl>
1 Gentoo  Biscoe   5200 female    5.2 
2 Gentoo  Biscoe   5200 female    5.2 
3 Gentoo  Biscoe   5150 female    5.15
4 Gentoo  Biscoe   5100 female    5.1 
5 Gentoo  Biscoe   5050 female    5.05

Resumir por grupos:

penguins |>
  filter(!is.na(body_mass_g)) |>
  group_by(species, island) |>
  summarise(n = n(),
            masa_media = mean(body_mass_g),
            masa_sd = sd(body_mass_g),
            .groups = "drop")
# A tibble: 5 × 5
  species   island        n masa_media masa_sd
  <fct>     <fct>     <int>      <dbl>   <dbl>
1 Adelie    Biscoe       44      3710.    488.
2 Adelie    Dream        56      3688.    455.
3 Adelie    Torgersen    51      3706.    445.
4 Chinstrap Dream        68      3733.    384.
5 Gentoo    Biscoe      123      5076.    504.

Seleccionar el máximo de cada grupo

Un problema frecuente es quedarse con la fila de mayor valor de cada grupo. slice_max() lo resuelve sin código complicado:

penguins |>
  filter(!is.na(body_mass_g)) |>
  group_by(species) |>
  slice_max(body_mass_g, n = 1, with_ties = FALSE) |>
  select(species, island, sex, body_mass_g)
# A tibble: 3 × 4
# Groups:   species [3]
  species   island sex   body_mass_g
  <fct>     <fct>  <fct>       <int>
1 Adelie    Biscoe male         4775
2 Chinstrap Dream  male         4800
3 Gentoo    Biscoe male         6300

Varias columnas a la vez con across()

penguins |>
  group_by(species) |>
  summarise(across(where(is.numeric), \(x) mean(x, na.rm = TRUE)))
# A tibble: 3 × 6
  species   bill_length_mm bill_depth_mm flipper_length_mm body_mass_g  year
  <fct>              <dbl>         <dbl>             <dbl>       <dbl> <dbl>
1 Adelie              38.8          18.3              190.       3701. 2008.
2 Chinstrap           48.8          18.4              196.       3733. 2008.
3 Gentoo              47.5          15.0              217.       5076. 2008.

4. Unir tablas

Cuando la información está repartida en varias tablas se unen por una llave (columna en común). Los tipos de unión más usados son:

Función Conserva
left_join(x, y) todas las filas de x
inner_join(x, y) solo las filas con pareja en ambas
full_join(x, y) todas las filas de ambas
anti_join(x, y) filas de x sin pareja en y
estaciones <- tibble(estacion = c("E01", "E02", "E03", "E05"),
                     municipio = c("Tumaco", "Buenaventura", "Bahía Solano", "Nuquí"))

leidos |> left_join(estaciones, by = "estacion")     # conserva las 4 muestras
# A tibble: 4 × 5
  estacion fecha      temp_c salinidad municipio   
  <chr>    <date>      <dbl>     <dbl> <chr>       
1 E01      2025-03-01   26.4      34.9 Tumaco      
2 E02      2025-03-01   27.1      35.2 Buenaventura
3 E03      2025-03-02   NA        35   Bahía Solano
4 E04      2025-03-02   25.8      34.7 <NA>        
leidos |> inner_join(estaciones, by = "estacion")    # solo las que tienen municipio
# A tibble: 3 × 5
  estacion fecha      temp_c salinidad municipio   
  <chr>    <date>      <dbl>     <dbl> <chr>       
1 E01      2025-03-01   26.4      34.9 Tumaco      
2 E02      2025-03-01   27.1      35.2 Buenaventura
3 E03      2025-03-02   NA        35   Bahía Solano
leidos |> anti_join(estaciones, by = "estacion")     # muestras sin municipio
# A tibble: 1 × 4
  estacion fecha      temp_c salinidad
  <chr>    <date>      <dbl>     <dbl>
1 E04      2025-03-02   25.8      34.7

Combinar varios archivos en uno

Si tienes un archivo por mes (o por estación) con las mismas columnas, léelos todos y apílalos:

carpeta <- file.path(tempdir(), "mensual")
dir.create(carpeta, showWarnings = FALSE)

for (mes in c("2025-01", "2025-02", "2025-03")) {
  tibble(mes = mes, estacion = c("E01", "E02"), lluvia_mm = round(runif(2, 0, 200))) |>
    write_csv(file.path(carpeta, paste0("lluvia_", mes, ".csv")))
}

archivos <- list.files(carpeta, pattern = "\\.csv$", full.names = TRUE)
todos <- map_dfr(archivos, read_csv, show_col_types = FALSE)
todos
# A tibble: 6 × 3
  mes     estacion lluvia_mm
  <chr>   <chr>        <dbl>
1 2025-01 E01            140
2 2025-01 E02            111
3 2025-02 E01             28
4 2025-02 E02             57
5 2025-03 E01            111
6 2025-03 E02              5

5. Texto, fechas y factores

Texto con stringr

Un problema típico es que los códigos pierden los ceros a la izquierda al importarlos ("007" se vuelve 7). str_pad() los recupera:

codigos <- c(7, 42, 105)
str_pad(codigos, width = 4, side = "left", pad = "0")
[1] "0007" "0042" "0105"
nombres <- c("  Bogotá D.C. ", "MEDELLÍN", "cali")
nombres |> str_trim() |> str_to_title()
[1] "Bogotá D.c." "Medellín"    "Cali"       
str_detect(nombres, regex("cali", ignore_case = TRUE))
[1] FALSE FALSE  TRUE

Fechas con lubridate

Las fechas se guardan como números (días desde 1970-01-01), lo que permite restarlas y extraer componentes. Las funciones ymd(), dmy(), mdy() interpretan el orden que tenga el texto:

f <- dmy(c("15/03/2025", "01/07/2025", "31/12/2025"))
f
[1] "2025-03-15" "2025-07-01" "2025-12-31"
year(f); month(f, label = TRUE); wday(f, label = TRUE)
[1] 2025 2025 2025
[1] Mar Jul Dec
12 Levels: Jan < Feb < Mar < Apr < May < Jun < Jul < Aug < Sep < ... < Dec
[1] Sat Tue Wed
Levels: Sun < Mon < Tue < Wed < Thu < Fri < Sat
f[2] - f[1]                          # diferencia en días
Time difference of 108 days
interval(f[1], f[3]) / months(1)     # diferencia en meses
[1] 9.516129
seq(f[1], by = "month", length.out = 4)
[1] "2025-03-15" "2025-04-15" "2025-05-15" "2025-06-15"
diario <- tibble(fecha = seq(ymd("2025-01-01"), ymd("2025-06-30"), by = "day"),
                 lluvia = rgamma(181, shape = 0.6, scale = 8))

diario |>
  mutate(mes = floor_date(fecha, "month")) |>
  group_by(mes) |>
  summarise(total_mm = sum(lluvia)) |>
  ggplot(aes(mes, total_mm)) +
  geom_col(fill = "#2a9d8f") +
  scale_x_date(date_labels = "%b") +
  labs(x = NULL, y = "Lluvia mensual (mm)") +
  theme_minimal(base_size = 12)
Figure 2: Agrupar por mes con floor_date() es la forma más simple de pasar de datos diarios a mensuales.

Factores con forcats

Un factor es una variable categórica con un conjunto fijo de niveles, y su orden define cómo se ordenan tablas y gráficos.

penguins |>
  filter(!is.na(body_mass_g)) |>
  group_by(species) |>
  summarise(masa = mean(body_mass_g)) |>
  mutate(species = fct_reorder(species, masa)) |>
  ggplot(aes(masa, species)) +
  geom_col(fill = "#e76f51") +
  labs(x = "Masa media (g)", y = NULL) +
  theme_minimal(base_size = 12)
Figure 3: fct_reorder() ordena las categorías por su valor, lo que hace el gráfico mucho más fácil de leer.

Otras funciones útiles: fct_relevel() (cambiar el orden a mano), fct_recode() (renombrar niveles), fct_lump_n() (agrupar niveles raros en “Otros”) y droplevels() (descartar niveles sin datos).

6. Datos faltantes

Antes de decidir qué hacer con los NA hay que preguntarse por qué faltan (Rubin, 1976):

  • MCAR (missing completely at random): la probabilidad de faltar no depende de nada. Eliminar los casos incompletos no sesga, solo pierde información.
  • MAR (missing at random): depende de otras variables observadas (por ejemplo, los hombres responden menos una pregunta). Se puede corregir usando esas variables.
  • MNAR (missing not at random): depende del propio valor faltante (quien tiene ingresos altos no los declara). Es el caso más difícil y no se corrige con métodos automáticos.

Primero, contar dónde faltan los datos:

penguins |>
  summarise(across(everything(), \(x) sum(is.na(x)))) |>
  pivot_longer(everything(), names_to = "variable", values_to = "faltantes") |>
  filter(faltantes > 0)
# A tibble: 5 × 2
  variable          faltantes
  <chr>                 <int>
1 bill_length_mm            2
2 bill_depth_mm             2
3 flipper_length_mm         2
4 body_mass_g               2
5 sex                      11

Luego, explorar si faltan de forma aleatoria: ¿hay más NA en el sexo según la especie o la isla?

penguins |>
  group_by(species, island) |>
  summarise(prop_sexo_faltante = mean(is.na(sex)), n = n(), .groups = "drop")
# A tibble: 5 × 4
  species   island    prop_sexo_faltante     n
  <fct>     <fct>                  <dbl> <int>
1 Adelie    Biscoe                0         44
2 Adelie    Dream                 0.0179    56
3 Adelie    Torgersen             0.0962    52
4 Chinstrap Dream                 0         68
5 Gentoo    Biscoe                0.0403   124

Las opciones más simples para tratarlos son: eliminar (drop_na()), que es razonable si es MCAR y son pocos casos, o imputar (reemplazar por un valor razonable), por ejemplo con la mediana del grupo:

completos <- penguins |> drop_na(body_mass_g, sex)
nrow(penguins); nrow(completos)
[1] 344
[1] 333
imputado <- penguins |>
  group_by(species) |>
  mutate(masa_imputada = coalesce(body_mass_g, median(body_mass_g, na.rm = TRUE))) |>
  ungroup()

imputado |> filter(is.na(body_mass_g)) |> select(species, body_mass_g, masa_imputada)
# A tibble: 2 × 3
  species body_mass_g masa_imputada
  <fct>         <int>         <dbl>
1 Adelie           NA          3700
2 Gentoo           NA          5000
WarningCuidado con la imputación simple

Reemplazar por la media o la mediana reduce artificialmente la variabilidad y puede sesgar relaciones entre variables. Para análisis formales usa imputación múltiple (paquete mice) e incluye el mecanismo supuesto en la discusión de resultados.

Ejercicios

  1. Importar. Escribe el tibble muestras a un CSV con write_csv(), léelo de nuevo y comprueba con all.equal() que es idéntico al original.
  2. Ordenar. La tabla ancho tiene un país por fila y un año por columna. Conviértela a formato largo y calcula el crecimiento porcentual entre 2020 y 2022 de cada país.
  3. Resumir. Con penguins, calcula la media del largo del pico (bill_length_mm) por especie y sexo, ignorando faltantes. ¿Qué grupo tiene el pico más largo?
  4. Unir. Crea una tabla con la altitud de cada una de las estaciones E01–E04 y únela a leidos.
  5. Fechas. ¿Cuántos días hay entre 2025-03-15 y el 1 de enero de 2026? ¿Qué día de la semana cae?
# 1
tmp <- tempfile(fileext = ".csv"); write_csv(muestras, tmp)
all.equal(muestras, read_csv(tmp, show_col_types = FALSE), check.attributes = FALSE)
[1] TRUE
# 2
ancho |>
  pivot_longer(-pais, names_to = "anio", values_to = "pob") |>
  group_by(pais) |>
  summarise(crecimiento_pct = (pob[anio == "2022"] / pob[anio == "2020"] - 1) * 100)
# A tibble: 3 × 2
  pais     crecimiento_pct
  <chr>              <dbl>
1 Chile               1.03
2 Colombia            2.36
3 Perú                2.12
# 3
penguins |>
  filter(!is.na(sex)) |>
  group_by(species, sex) |>
  summarise(pico_medio = mean(bill_length_mm, na.rm = TRUE), .groups = "drop") |>
  arrange(desc(pico_medio))
# A tibble: 6 × 3
  species   sex    pico_medio
  <fct>     <fct>       <dbl>
1 Chinstrap male         51.1
2 Gentoo    male         49.5
3 Chinstrap female       46.6
4 Gentoo    female       45.6
5 Adelie    male         40.4
6 Adelie    female       37.3
# 5
d <- ymd("2026-01-01") - ymd("2025-03-15")
d
Time difference of 292 days
wday(ymd("2025-03-15"), label = TRUE, abbr = FALSE)
[1] Saturday
7 Levels: Sunday < Monday < Tuesday < Wednesday < Thursday < ... < Saturday

Para profundizar

NoteReproducibilidad

Este cuaderno se ejecutó con R 4.5.2 y dplyr 1.2.1.