Análisis exploratorio en R: del CSV al insight en 50 líneas
Una pasada real por el flow que uso para destripar un CSV nuevo sin saber qué hay dentro: lectura, perfilado, limpieza, agrupación y primeras gráficas. Todo en un script de R que cabe en una pantalla.
Cuando un cliente me pasa un CSV de "ventas del último año" y no me cuenta nada más, hago siempre el mismo ritual antes de prometer nada: 50 líneas de R que me dicen qué tengo entre manos. No es nada sofisticado, pero me ha ahorrado más cantadas que cualquier biblioteca cara.
Este post es ese script, explicado paso a paso, con un dataset real anonimizado: ventas de entradas de un evento musical (≈140k filas).
#Por qué R y no Python para esto
Para EDA puro (exploratory data analysis) R sigue siendo más rápido de teclear que Python. dplyr + ggplot2 son sintaxis declarativa de verdad: pides un agregado y te lo da. En Python necesito 3 imports y nombrar el groupby resultante. Para producción reservo Python; para "qué cojones es este CSV", R.
Si no tienes R: instala R 4.x + RStudio (gratis) o usa Positron (el "VSCode para R" de Posit). Para correr este post: install.packages(c("tidyverse", "janitor", "skimr", "lubridate")).
#El dataset
# ventas.csv (140.231 filas, 18 columnas)
# Cabecera real:
# transaction_id, event_id, event_name, ticket_type, price_eur,
# discount_eur, channel, customer_country, customer_postal_code,
# created_at, status, refund_reason, ...Lo típico: nombres inconsistentes, fechas en strings, importes con comas, NULLs disfrazados de "N/A". Como casi todo lo que llega del mundo real.
#Paso 1 — Cargar con armadura
library(tidyverse)
library(janitor)
library(skimr)
library(lubridate)
ventas <- read_csv(
"ventas.csv",
na = c("", "NA", "N/A", "null"), # NULLs disfrazados
show_col_types = FALSE
) |>
clean_names() # snake_case automático
dim(ventas)
# [1] 140231 18janitor::clean_names() me ahorra horas. Convierte "Customer Postal Code" a customer_postal_code y nunca tengo que volver a pensar en comillas raras.
#Paso 2 — Perfilado en una línea
skim(ventas)skimr::skim() es una bomba. Te suelta una tabla por tipo de columna con: completitud, media, sd, percentiles, top valores, histograma ASCII. En 1 línea tienes una foto del dataset completo.
Si solo te enseño una función de R, que sea skimr::skim(). Es lo primero que ejecuto sobre cualquier dataframe nuevo, antes incluso de mirar head().
Lo que detecté en este dataset:
price_eur: 3% de NAs (raros, porque debería estar siempre)customer_postal_code: 41% NAs (esperado, no es obligatorio)refund_reason: 97% NAs (lógico, solo aplica si hay reembolso)created_at: tipocharacter, nodatetime→ bandera roja
#Paso 3 — Tipos y validación
ventas <- ventas |>
mutate(
created_at = ymd_hms(created_at),
price_eur = as.numeric(price_eur),
is_refund = !is.na(refund_reason),
month = floor_date(created_at, "month")
)
# Sanity checks
ventas |>
summarise(
rows = n(),
distinct_tx = n_distinct(transaction_id),
distinct_event = n_distinct(event_id),
date_min = min(created_at, na.rm = TRUE),
date_max = max(created_at, na.rm = TRUE),
total_eur = sum(price_eur, na.rm = TRUE)
)Tres de cada cinco veces, n_distinct(transaction_id) != n() te dice que hay duplicados que no esperabas. Ese sanity check ha salvado el culo de mi cliente más de una vez.
#Paso 4 — Agrupación rápida
# Ventas por canal y mes
por_canal <- ventas |>
filter(status == "completed", !is_refund) |>
group_by(month, channel) |>
summarise(
tickets = n(),
gmv_eur = sum(price_eur, na.rm = TRUE),
.groups = "drop"
) |>
arrange(desc(month), desc(gmv_eur))
print(por_canal, n = 20)Aquí ya empieza a salir el patrón: en este dataset, direct_web se come el 60% del GMV pero affiliate_tier1 tiene ticket medio 38% más alto. Cosas accionables para el cliente.
#Paso 5 — Primera gráfica para el cliente
ggplot(por_canal, aes(x = month, y = gmv_eur, fill = channel)) +
geom_col(position = "stack") +
scale_y_continuous(labels = scales::label_number(scale = 1e-3, suffix = "k €")) +
scale_fill_brewer(palette = "Set2") +
labs(
title = "GMV mensual por canal de venta",
subtitle = "Eventos 2025–2026 · n = 140.231 transacciones",
x = NULL, y = NULL,
fill = "Canal",
caption = "Fuente: ventas.csv · análisis Dumaloor"
) +
theme_minimal(base_size = 13) +
theme(legend.position = "bottom")ggplot2 con theme_minimal() ya da gráficas que puedes pegar en un Notion sin avergonzarte. Cuando necesito layout más cuidado, encadeno patchwork para combinar 2-3 gráficas en una.
#Paso 6 — Detección de outliers rápida
ventas |>
filter(status == "completed", !is_refund) |>
group_by(event_id, event_name) |>
summarise(
n = n(),
avg_pvp = mean(price_eur, na.rm = TRUE),
p95_pvp = quantile(price_eur, 0.95, na.rm = TRUE),
.groups = "drop"
) |>
arrange(desc(avg_pvp)) |>
head(10)Si el p95_pvp está 20× por encima del avg_pvp, tienes un evento VIP escondido que el cliente seguramente no había aislado. Esto pasa a menudo.
#Paso 7 — Exportar el resumen
por_canal |>
pivot_wider(
names_from = channel,
values_from = c(tickets, gmv_eur),
values_fill = 0
) |>
write_csv("resumen_canal_mes.csv")pivot_wider con values_fill = 0 te deja la matriz tipo Excel que el financiero va a entender sin abrir RStudio.
#Cierre
Esto es el 80% de lo que necesito antes de proponer cualquier modelo o pipeline. El 20% restante es contexto del negocio: qué mide el cliente, qué decisión va a tomar con esto, qué está roto en upstream. Eso no se saca de R, se saca preguntando.
Cuando llegue un CSV nuevo: read_csv con armadura → clean_names → skim → tipos → sanity checks → 1 agrupación → 1 gráfica → exportar resumen. En 50 líneas tienes algo que enseñar.
En el próximo post comparo este flow con el equivalente en Python (pandas + polars) en un dataset de 8M filas, donde R empieza a sufrir y Polars vuela. Spoiler: la respuesta no es "uno u otro".