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.
Un análisis exploratorio en R sobre un CSV que nadie te ha explicado son siete pasos encadenados con el operador |>, ninguno de más de cinco líneas: leer el archivo con read_csv() reforzado contra los NA disfrazados de texto, perfilarlo con skimr::skim() en una sola línea para ver completitud, tipos y valores extremos de golpe, corregir tipos con mutate(), comprobar duplicados con n_distinct(), agrupar con group_by() y summarise() para sacar el primer patrón real, dibujarlo con ggplot2 y exportar el resumen con pivot_wider() a un CSV que cualquiera entienda. Sirven para tener algo defendible antes de prometer nada sobre un conjunto de datos que acabas de ver por primera vez.
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, sobre un dataset de ejemplo inventado para la ocasión: ventas de entradas de un evento cualquiera, unas 140.000 filas. Los números que salen son ilustrativos; lo que importa es el flujo.
#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.000 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] 140000 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í es donde empieza a salir el patrón que buscas: un canal que concentra la mayor parte del importe pero con ticket medio bajo, frente a otro que vende menos y con ticket más alto. Con eso ya se puede hablar de dónde conviene invertir; sin ello, solo hay intuiciones.
#Paso 5 — Primera gráfica que se entienda
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.000 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".
Preguntas frecuentes
¿Este análisis exploratorio funciona igual si el CSV no cabe en RAM?
No sin cambios. Estos siete pasos asumen que read_csv() puede cargar el archivo entero en memoria, algo razonable hasta un par de gigas. Por encima de eso, skim() sobre un objeto que ya ahogó la RAM ni llega a ejecutarse: hace falta leer el archivo sin cargarlo entero antes de perfilar nada, y en R eso pasa por el paquete arrow o por duckdb, que consultan el archivo desde disco y solo traen a memoria las columnas y filas que pide la consulta.
¿Qué pasa si clean_names() deja dos columnas con el mismo nombre?
La función janitor::clean_names() añade un sufijo numérico automático (_2, _3 y siguientes) a los duplicados en vez de fallar, así que el script sigue corriendo. Conviene revisar names(ventas) justo después de cargar para detectar esos sufijos: casi siempre delatan dos columnas que en el archivo original eran, en realidad, la misma cosa exportada dos veces.
¿skim() rinde igual de bien con columnas de texto libre largo, como comentarios o descripciones?
Ahí rinde poco. La función skim() está pensada para columnas categóricas o numéricas; con texto libre largo solo aporta el conteo de NA y la longitud media, y no sustituye un análisis de texto. Si el CSV trae una columna de comentarios, conviene excluirla del perfilado con select(-columna) y tratarla aparte.
Seguir leyendo
De Excel a R: migrando análisis financieros reales
Sustituir un Excel financiero de 30 pestañas que tarda 8 minutos en abrir por un script de R que tarda 2 segundos. Pasos reales, problemas reales, y los argumentos para convencer al financiero de que merece la pena.
Python vs R para análisis de datos: cuándo usar cada uno
Llevo años alternando entre R y Python para análisis. Esta es la decisión real, sin religión: qué uso para cada cosa y por qué. Sin defender a ninguno como ganador absoluto.