Saltar al contenido
dumaloor.dev_
~/academia
R

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.

5 min de lecturartidyverseedadplyrggplot2

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.

Setup mínimo

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

r5 líneas
# 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

r14 líneas
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     18

janitor::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

r1 líneas
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: tipo character, no datetime → bandera roja

#Paso 3 — Tipos y validación

r18 líneas
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

r12 líneas
# 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

r13 líneas
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

r11 líneas
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

r7 líneas
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.

Receta

Cuando llegue un CSV nuevo: read_csv con armadura → clean_namesskim → 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".