Tema 02 - Limpieza y Tratamiento de Datos

De datos en bruto a información útil para decidir con tidyverse

Pedro Albarrán

Dpto. de Fundamentos del Análisis Económico. Universidad de Alicante

Alberto Pérez

Introducción. Datos Ordenados

Limpieza y “doma” de datos

  • El ciclo de vida de los datos

  • El desafío real del análisis de datos:

    • 80% del tiempo de trabajo “sucio” : limpieza y preparación

    • 20% del tiempo: análisis y modelización

  • tidyverse incluye una colección de bibliotecas con herramientes eficientes para el proceso de “tratamiento de datos” (“data wrangling”)

  • El objetivo es obtener un conjunto de datos ordenado y limpio para realizar el análisis eficientemente y obtener información útil para la toma de decisiones

Datos ordenados (‘tidy data’): Principios fundamentales

1.- Cada columna es una variable: mide el mismo atributo entre unidades

2.- Cada fila es una observación (caso): misma unidad a través de atributos

3.- Cada celda es un valor

  • ¿Qué es una unidad en la tabla? ¿a qué nivel medimos la información?

    • a nivel de cada compra realizada por un cliente en cada fecha, o

    • a nivel de cliente (todas sus compras), de tienda (todas sus ventas), etc.

  • Tenemos información similar y no redundante en una misma tabla

Datos no ordenados

  • Otras estructuras como esta pueden tener sentido para mostrar información (o por convenciones)

  • La visualización es atractiva, PERO sobran filas para analizar los datos: ej., total de personas con hijos y sin pareja entre 30 y 39 años

Caso de Estudio: RetailCorp

  • Contexto: Cadena de venta al por menor con 12 tiendas en España

  • Objetivo: Analizar rendimiento de ventas para toma de decisiones estratégicas

load("data/retail_data.RData")
  • Recibe datos de

    • Sistema de ventas (POS)

    • CRM de clientes

    • Inventario de productos

    • RRHH de empleados

    • Informes Excel de gerentes

Problemas Típicos:

  • Formatos inconsistentes

  • Datos duplicados

  • Valores ausentes

  • Estructuras inadecuadas

  • Tablas dispersas

Transformación de datos (una tabla)

Funciones de transformación de datos

  • La mayoría de operaciones pueden realizarse combinando 5 “verbos”:

    1. select(): selecciona columnas (variables)

    2. filter(): filtra (extraer) filas

    3. mutate(): crea nuevas columnas

    4. arrange(): ordena filas

    5. summarize(): crea resúmenes de la tabla

    • Más la tubería %>% o |>

    • y group_by()

  • NOTA: existe una colección de “chuletas” de R, p.e., para transformación.

  • Todos tienen como primer argumento un data frame, los siguientes describen qué hacer (con columnas o filas) y devuelven otro data frame

1. select()

  • Selecciona variables por nombres o posiciones de columnas, separados por comas
  • Ej., un analista solo necesita información básica de ventas
select(ventas, id_venta, fecha, id_tienda, id_producto, total)

select(ventas, 1:2, 5, 4, 12)
  • Aplicación Empresarial: el equipo de marketing solo necesita información de cliente y venta
ventas_mkt <- select(ventas, 
                fecha, id_cliente, id_producto, total)

2. filter()

  • Conserva filas en las que una condición lógica (o varias separadas por comas) es verdadera
  • Caso de Uso: Gerente quiere analizar ventas específicas con determinadas características
ventas_top      <- filter(ventas, total > 1000)

ventas_ene_2024 <- filter(ventas, año == 2024, mes == 1)

ventas_mad_bcn  <- filter(ventas, id_tienda %in% c(1, 2))

ventas_premium  <- filter(ventas, 
                     total > 1000 & descuento_porcentaje == 0)

Encadenando operaciones con tuberías: %>% o |>

  • Las operaciones encadenadas crean objetos intermedios o no son legibles
ventas_top        <- filter(ventas, total > 1000)
ventas_top_markt  <- select(ventas_top, 
                       fecha, id_cliente, id_producto, total)

ventas_top_markt <- select(filter(ventas, total > 1000),
                      fecha, id_cliente, id_producto, total)
  • datos %>% filter(condicion) equivale a filter(datos, condicion)

  • El anidamiento con tuberías sigue el flujo natural de lectura

    • Toma una tabla y pásala a un comando que acepta y produce un data frame
    • Toma la nueva tabla resultante y pásala a otro comando
ventas_top_markt <- ventas |>                   
                     filter(total > 1000) |>     
                     select(fecha, id_cliente, id_producto, total)

Funciones auxiliares de selección (de columnas)

# Por rango de columnas
ventas |> select(id_venta:id_tienda)

# Excluir columnas
ventas |> select(-descuento_porcentaje, -descuento_aplicado)

# Por patrón de nombre
ventas |> select(starts_with("id_"))
ventas |> select(ends_with("_porcentaje"))
ventas |> select(contains("descuento"))

# Por tipo de dato
ventas |> select(where(is.numeric))
ventas |> select(where(is.character))
  • pull(): extrae una única columna, como vector
ventas |> pull(cantidad) |> mean()

3. mutate()

  • Crea o modifica variables mediante una fórmula a partir de otras columnas

ventas2 <- ventas |>
  mutate(
    precio_final_unitario = total / cantidad,
    es_inicio_mes         = day(fecha) <= 7
  )
  • Funciones para operar con fechas (usando lubridate)
ventas_tiempo <- ventas |>
  mutate(
    fecha_completa = as.Date(fecha),  # tipo de objeto "fecha"
    semana_año = week(fecha),
    nombre_mes = month(fecha, label = TRUE),
    dias_desde_venta = Sys.Date() - fecha
  )

4. arrange()

  • re-ordena las filas de un data frame (todas las columnas)

    • en orden ascendente (por defecto) o descendente con desc()

  • Caso de Uso: Top 5 ventas más altas
ventas |> 
  arrange(desc(total)) |> slice(5) |> 
  select(id_venta, fecha, total)  
  • Ordenamientos múltiples: ordena por la primera variable y luego, en caso de empate, por la siguiente, etc.
ventas |>
  arrange(id_tienda, desc(total)) |>
  select(id_tienda, id_venta, total)

5. summarize()

  • Crea un nuevo conjunto de datos de una sola fila, con variables nuevas de un solo valor que resumen los datos completos

  • Caso de Uso: KPIs para el dashboard ejecutivo
KPIs <- ventas |>
  summarize(
    total_ventas       = n(),           # Volumen (núm. de filas)
    ingresos_totales   = sum(total),    # Ingresos
    ingresos_promedio  = mean(total),
    ingresos_mediano   = median(total),
    descuento_promedio = mean(descuento_porcentaje), # Descuentos
    descuento_total    = sum(descuento_aplicado),
    unidades_vendidas  = sum(cantidad),             # Productos
    clientes_unicos    = n_distinct(id_cliente)     # Clientes 
                         # (núm. de filas distintas)
  )

group_by(): Análisis por Grupos

  • Cambia el alcance: cada función actúa dentro de grupos, no sobre toda la tabla

  • Ejemplo: encontrar la fecha con la mayor venta por tienda

ventas |> group_by(id_tienda) |>
  arrange(desc(total)) |>  
  slice(1) |>                     # Fila 1 (de cada grupo)
  select(id_tienda, fecha, total)
  • group_by() + summarize() = cambia el nivel de análisis, agregando, p.e., de transacciones a tiendas, productos, etc.

    • En Excel: Tablas dinámicas, AGRUPARPOR() (y SUMAR.SI/SUMIF)
ventas                             # tabla a nivel de transacción
ventas |>
  summarize(ingresos = sum(total)) # resumen global
ventas |>
  group_by(id_tienda) |>
  summarize(ingresos = sum(total)) # resumen por tienda
                                   # tabla a nivel de tienda

group_by() (cont.)

  • group_by() + summarize() reduce filas: nueva tabla agregada a nivel de los grupos
tabla_sum <- ventas |> group_by(id_tienda) |>
  summarize(ingresos = sum(total))
  • group_by() + mutate() mantiene filas: añade columnas calculadas por grupo a nivel de la tabla original
tabla_mut <- ventas |> group_by(id_tienda) |>
  mutate(ingresos = sum(total))
  • Ejemplo: Porcentaje de las ventas que representa cada transacción
ventas |> group_by(id_tienda) |>
  mutate(
    ventas_tienda = sum(total),
    pct_de_ventas = total / ventas_tienda * 100
  )

ungroup()

  • IMPORTANTE: No olvidar ungroup() o .groups = "drop" después de terminar operaciones agrupadas

  • Cálculo de Porcentajes Globales: sin desagrupar, el segundo sum(total) suma por tienda → siempre da 100%

ventas |>
  group_by(id_tienda) |>
  mutate(ventas_tienda = sum(total)) |>
  ungroup() |>
  mutate(porcentaje = ventas_tienda / sum(total) * 100)

ventas |>
  group_by(id_tienda) |>
  mutate(ventas_tienda = sum(total),
         .groups = "drop") |>
  mutate(porcentaje = ventas_tienda / sum(total) * 100)

Funciones auxiliares para filas

  • Extraer filas pero NO por condición: por posición (slice(), slice_head()), aleatoriamente (slice_sample()), etc.
ventas |> slice_max(total, n = 5) # Top 5 ventas
ventas |> slice_sample(n = 100)   # sub-muestra aleatoria
  • distinct(): extrae sólo las filas únicas (una o varias variables)
ventas %>% distinct(id_producto)
  • drop_na() y replace_na(): elimina/reemplaza filas con valores ausentes
# Quita filas con NA en cualquier variable
ventas_completas <- ventas %>%
  drop_na()                    

# solo quita si precio_unitario es NA
ventas_completas_precio <- ventas %>%
  drop_na(precio_unitario)  

Otras funciones auxilidares de tidyverse

  • Muchas funciones son equivalentes a otras de R base:

    • parse_number(), parse_factor(), etc. por as.numeric(), as.factor(), etc.

    • bind_cols() y bind_rows() por cbind() y rbind()

    • if_else() y case_when() para ejecución condicional (ifelse())

ventas |>
  mutate(
    tipo_venta = if_else(total > 1000, "Alta", "Baja"), # condición simple
    categoria_cliente = case_when(              # múltiples condiciones
      total < 300  ~ "Económico",
      total < 1000 ~ "Estándar",
      TRUE         ~ "Premium"               # OJO: convertir a factor
    )
  )
  • Discretizar variables: cut_interval(), cut_number(), cut_width()
  • Funciones para fechas de lubridate: year(), month(), day(), quarter(), week()

Funciones auxiliares de creación de variables

  • rename(): cambiar el nombre de una columna
ventas_renamed <- ventas |>
  rename(
    fecha_venta = fecha,
    monto_total = total
  )
  • across(): aplica la misma transformación a múltiples columnas
ventas |> mutate(across(c(cantidad, subtotal:total), ~ log(.x)))
ventas |> mutate(across(where(is.character), ~ parse_factor(.x)))
  • Operadores aritméticos (+, -, *, /, ^, %/%, %%) y lógicos (<, <=, >, >=, !=)

  • Funciones como log(), lag(), lead(), cumsum(), row_number() etc.

Funciones auxiliares de resumen

  • count(): cuenta los valores únicos de una o más variables
ventas |> count(id_tienda)
# ventas |> group_by(id_tienda) |>  summarize(n = n())
ventas |> count(id_tienda, sort = TRUE)
  • Medidas de centralidad y de dispersión: mean(x), median(x), sd(x), IQR(x)

  • Medidas de rango: min(x), quantile(x, 0.25), max(x)

  • Medidas de posición: first(x), nth(x, 2), last(x).

  • Sumas, productos, etc.

  • Conteos:

    • n(): observaciones totales (tamaño del grupo)

    • n_distinct(x): filas distintas en x

Operaciones Adicionales de Limpieza

  • separate(): dividir una columna por caracter o posición
  • unite(): combinar columnas
  • Para nombres de columnas con espacios o caracteres especiales, debemos usar acento invertido

    • Mejor: renombrar sin espacios
datos_problema |> 
  rename(nombre_producto = `Nombre Producto`,
         precio_euros    = `Precio (€)`)

Transformación de Datos: Pivotar

Mismos datos, dos formatos: ancho o largo

Formato ANCHO:

tienda Q1 Q2 Q3 Q4
Madrid 145 158 151 169
Barcelona 152 164 156 175
Valencia 138 151 149 162


ventas_ancho

(datos del informe enviado por gestores)

Formato LARGO:

tienda trimestre ventas
Madrid Q1 145
Madrid Q2 158
Madrid Q3 151
Madrid Q4 169
Barcelona Q1 152
  • Los trimestres son columnas

  • Cada tienda = 1 fila

  • Análisis de datos a menudo complicado

  • Los trimestres son valores

  • Cada tienda-trimestre = 1 fila

  • No adecuado para tablas de presentación final

pivot_longer(): girar de ancho a largo

  • Girar para analizar los datos
ventas_largo <- ventas_ancho |>
  pivot_longer(
    cols = Q1:Q4,              
    names_to = "trimestre",    
    values_to = "ventas"       
  )

  1. tabla a cambiar de forma

  2. nombres o índices (numéricos) de las columnas a girar: representan valores, no variables

  3. nombre para la nueva variable que tendrá, como valores, esas antiguas columnas a girar

  4. nombre para la nueva variable que tendrá como valores las antiguas celdas

pivot_wider(): girar de largo a ancho

  • Girar para crear tabla de presentación
ventas_largo |>             
  pivot_wider(
    names_from = trimestre, 
    values_from = ventas    
  )

  1. tabla a cambiar de forma

  2. nombre de la variable cuyos valores dan nombre a las nuevas columnas

  3. nombre de la variable de cuyas celdas toman los valores las nuevas columnas

Comparación: Tareas Comunes

  • Según nuestro objetivos, podemos preferir formato ancho o largo

  • Problemas prácticos con formato ancho para analizar datos:

  1. Algunas tareas son imposibles. P.e., ¿qué trimestres superan 160 en ventas?
ventas_largo |>
  filter(ventas > 160)
  1. Código repetitivo y propenso a errores. P.e., Calcular crecimiento
ventas_ancho |>
  mutate(
    crec_Q2 = (Q2 - Q1) / Q1 * 100,
    crec_Q3 = (Q3 - Q2) / Q2 * 100,
    crec_Q4 = (Q4 - Q3) / Q3 * 100
  )

ventas_largo |>
  group_by(tienda) |>
  mutate(crecimiento = (ventas - lag(ventas)) / lag(ventas) * 100)

Comparación: Tareas Comunes (cont.)

  1. No escalable. P.e., gráfico temporal por grupos
ggplot(ventas_ancho) +
  geom_line(aes(x = 1:4, y = c(Q1[1], Q2[1], Q3[1], Q4[1])), color = "red") +
  geom_line(aes(x = 1:4, y = c(Q1[2], Q2[2], Q3[2], Q4[2])), color = "blue") +
  geom_line(aes(x = 1:4, y = c(Q1[3], Q2[3], Q3[3], Q4[3])), color = "green")

ggplot(ventas_largo, aes(x = trimestre, y = ventas, 
                         color = tienda, group = tienda)) +
  geom_line() + geom_point()
  • Formato ancho solo para tablas de presentación final
analisis <- ventas_largo |>
  group_by(trimestre) |>
  summarize(total = sum(ventas))

tabla_presentacion <- ventas_largo |>
  pivot_wider(names_from = trimestre, values_from = ventas) |>
  mutate(Total = Q1 + Q2 + Q3 + Q4)

Datos Relacionales

Por Qué Múltiples Tablas

  • Analizar datos suele implicar múltiples tablas

    • diferentes orígenes: ej., dptos. de empresa (personal, ventas, almacén)

    • almacenamiento más eficiente: elementos “similares” dentro de una tabla y diferentes entre ellas

  • Para poder combinar información los datos deben ser relacionales: cada par de tablas están relacionadas mediante identificadores comunes llamados claves

    • Primaria (o interna): identifican de forma única cada observación en una tabla. Puede ser una sola variable o múltiples

    • Secundaria (o externa): señala a la clave primaria de otra tabla

  • Una clave primaria y su externa asociada en otra tabla forman una relación: de uno-a-muchos, de uno-a-uno, de muchos-a-muchos, de muchos-a-uno

Diagrama de Relaciones del Sistema RetailCorp

Uniones de transformación

  • Añaden nuevas variables a una tabla desde filas coincidentes en otra.
  • Tenemos estos datos.

  • ¿Cómo conseguimos unirlos adecuadamente en R y en Excel?

  • Con cbind() o bind_columns() o copiando y pegando en Excel: nuevas columnas para filas en el mismo orden
tabla1 <- import("data/DosTablas.xlsx", sheet = 1)
tabla2 <- import("data/DosTablas.xlsx", sheet = 2)
union  <- inner_join(tabla1, tabla2, by = "Vendedor")

Tipos de Uniones: Visión General

  • Unión interna: inner_join(x, y) sólo incluye observaciones que coincidan en x y y.

  • Uniones externas: cuando una fila no coincide en una unión externa, las nuevas variables se rellenan como valores ausentes

left_join(x, y)

right_join(x, y)

full_join(x, y)

  • Pensar con cuidado el tipo de unión necesaria: qué observaciones quedan (todas, solo de una tabla y cuál) y ser conscientes de la introducción de NAs

Análisis de ventas por región en el tiempo

  • Para usar la información de región se deben unir las tablas de ventas y de tiendas
tabla_regional <- ventas |>
  left_join(tiendas, by = "id_tienda") |>   
  group_by(region, año, mes) %>%
  summarize( valor_ventas = sum(total),
             ticket_medio = mean(total),
             cantidad_media = mean(cantidad) ) |> ungroup()
  • Ahora podemos realizar más análisis
# Evolución de ventas por región
tabla_regional |> 
  mutate(periodo = ym(paste(año, mes, sep = "-"))) |>
  ggplot() + 
  geom_line(aes(x = periodo, y = valor_ventas, color = region))

# Los dos periodos con más ventas medias de cada región
tabla_regional |> 
  group_by(region) |>  
  arrange(desc(ticket_medio)) |> slice(1:2)

Sobre el argumento by

  • Las claves, es decir, las variables que relacionan ambas tablas, se indican con

    • by = "varX", cuando la clave es una variable

    • by = c("varX", "varY"), cuando son varias variables

  • Si los nombre son distintos en cada tabla, se empareja x1 en la primera con y1 en la segunda con by = c("x1" = "y1", "x2" = "y2")

  • Ej., calcular el bono medio de los Vendedores
empleados |> filter(puesto == "Vendedor") |>
  left_join(promociones_empleado, 
            by = c("id_empleado" = "cod_empleado", 
                   "id_tienda" = "cod_tienda") ) |> 
  pull(monto_bono) |> mean(na.rm = T)

Claves duplicadas

  • Si una coincidencia no es única, se generan todas las combinaciones posibles (producto cartesiano) de las observaciones coincidentes

  • Un uso habitual de las uniones: los nombres completos de categorías se ponen una tabla aparte y solo se une cuando se necesitan

# margen medio por categoría de producto
productos |> 
  mutate(margen = precio - costo) |> 
  select(id_categoria, margen) |> 
  right_join(categorias,                 # ¿por qué right?
             by = c("id_categoria" = "categoria")) |>
    group_by(nombre_categoria) |> 
  summarize(margen_medio = mean(margen))

Uniones de Filtrado

  • Filtran las observaciones de la tabla de la izquierda basándose en si coinciden o no con una observación de la otra tabla

  • Se tiene un subconjunto de las filas de la tabla de la izquierda

  • semi_join(x, y) mantiene las observaciones en x que están en y

  • anti_join(x, y) elimina las observaciones en x que están en y

Uniones de Filtrado (cont.)

  • Aplicación: ¿cuántos clientes tenemos y cuántos sí han comprado?
clientes |> distinct(id_cliente) |> count()
clientes |> semi_join(ventas) |> distinct(id_cliente) |> count()
  • Aplicación: ¿qué productos NO han sido devueltos?
  • Útiles para diagnosticar desajustes de uniones (qué observaciones serán emparejadas). Importante antes de unir tablas MUY grandes
# empleados sin información de ventas
empleados |> anti_join(ventas, 
                       by = c("id_empleado", "id_tienda")) |> 
  count(id_empleado, id_tienda)  
  • Pueden ser una alternativa simple a usar filter() con filtrados complejos (que involucran varias variables), creando previamente tablas resumidas

    -Ej., venta promedio de los 10 productos con más cantidad vendida

Operaciones de conjunto

  • Trabajan con filas completas, comparando valores de cada variable.

  • Esperan que x e y tengan las mismas variables, y tratan las observaciones (filas) como elementos de un conjunto.

  • Útil cuando se quiere dividir un filtro complejo en piezas más simples.

df1 <- tibble(x = 1:2, y = c(1, 1))
df2 <- tibble(x = c(1,1), y = 1:2)

intersect(df1, df2)     # solo filas tanto en df1 como en df2
union(df1, df2)         # filas únicas en ambas tablas df1 y df2` 
union_all(df1, df2)     # todas las filas de df1 y df2, manteniendo duplicados 
setdiff(df1, df2)       # filas en df1, pero no en df2
setdiff(df2, df1)   

Buenas Prácticas

  • Datos Ordenados

  • Código Legible y reproducible

    1. Scripts ordenados y comentados

    2. Encadenar operacion con |> en un paso por línea

    3. Inspeccionar resultados intermedios

    4. Documentar decisiones de limpieza

    5. Nombres descriptivos de variables

    6. Guardar datos procesados

  • Errores comunes

    1. No verificar claves duplicadas en uniones

    2. Orden de operaciones incorrecto: ej., filtar después de summarize() por una variable que ya no existe

Buenas Prácticas (cont.)

Eficiencia:

  1. Filtrar temprano, unir tarde
ventas_2023 <- ventas |>
  filter(año == 2023) |>    # reduce tamaño
  left_join(productos, by = "id_producto")

ventas_2023_lento <- ventas |>
  left_join(productos, by = "id_producto") |>
  filter(año == 2023)
  1. Seleccionar solo columnas necesarias