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

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.

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.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

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] 140000     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í 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

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.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

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".

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

¿Te ha sido útil?

¿Necesitas algo similar para tu negocio?

Construyo dashboards, pipelines y análisis a medida. Si quieres hablar, escríbeme.

Hablamos