Tema 04 - Análisis Exploratorio de Datos (AED)

Calidad de los datos y descriptiva con propósito

Pedro Albarrán

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

Henry Redondo

El AED: de los datos en bruto a la decisión

Cómo se trabaja este tema

Importante

El ejercicio del tema se va haciendo durante estas clases, no después: está en Tema 04 - Ejercicios del tema, y son cuatro ejercicios que se corresponden con las tres PRÁCTICAS de estas transparencias más un cuarto con datos del INE. Cada vez que toque, aparecerá un aviso como este.

  • Los datos son marketplace.RData, de la página Datos: un volcado del sistema de un marketplace, con los problemas de calidad que trae de verdad
  • Hay un cuarto ejercicio con la EPA del INE, que se baja de su servicio web: es la preparación del primer ejercicio largo

¿Qué es el AED y para qué sirve?

Del fichero recibido a la decisión informada, con dos objetivos:

  1. Conocer los datos y su calidad: qué mide cada variable, qué falta, qué está mal, qué no es comparable

  2. Describir con propósito: los patrones que responden a una pregunta económica concreta

No hay receta: depende de los datos Y del objetivo, y es iterativo.

Por qué esto es un problema económico, no informático

  • Una decisión empresarial o de política se toma sobre un número: un margen, una tasa de paro, un ticket medio, una elasticidad

  • Ese número hereda todos los defectos de los datos que lo produjeron

    • Si el 30% de los clientes insatisfechos no contesta la encuesta, la satisfacción media no mide la satisfacción de los clientes

    • Si un vendedor declara su facturación en miles y otro en euros, la “empresa más grande” del ranking puede ser la más pequeña

  • La limpieza no es un trámite previo: cada decisión de limpieza es un supuesto sobre cómo se generaron los datos

  • Regla del tema: toda decisión se escribe, se justifica y se puede deshacer

Caso del curso: el marketplace MercadoLocal

  • Plataforma que conecta pequeños comercios con compradores en el Levante y Madrid

  • Dirección quiere responder a tres preguntas:

    1. ¿Cuánto gasta un cliente típico y en qué canal?

    2. ¿Qué vendedores son más productivos?

    3. ¿Crecemos o solo es la campaña de Navidad?

Tablas disponibles (simuladas)

  • pedidos_bruto: una fila por pedido, tal y como sale del sistema

  • vendedores: una fila por comercio, con datos de un formulario que rellenan ellos mismos

  • serie_ventas: ventas mensuales agregadas, 2023-2025

  • Usamos datos simulados para poder comprobar si la limpieza recupera la verdad. Al final del tema, datos reales del INE

Los datos tal y como llegan

pedidos_bruto |> glimpse()
variable qué debería ser qué llega
fecha fecha texto en DOS formatos distintos
importe número (euros) texto con coma decimal y punto de miles
canal factor de 3 niveles texto con mayúsculas y espacios
cp_envio texto de 5 dígitos número (ha perdido el cero inicial)
valoracion 1 a 10, o ausente incluye el valor centinela -99
descuento 0 a 1, o “sin promoción” NA con DOS significados distintos
  • Ninguno de estos problemas da un error al importar: el fichero se lee “bien” y todo parece correcto

Parte I. Detectar los problemas de calidad

Un protocolo de reconocimiento

  • Antes de tocar nada, cuatro preguntas siempre iguales:

    1. Tamaño y unidad de observación: ¿cuántas filas y qué es una fila? ¿coincide con lo que esperábamos?

    2. Tipos: ¿cada variable tiene el tipo que corresponde a lo que mide?

    3. Rango y categorías: ¿los valores son posibles? ¿las categorías son las del diccionario?

    4. Ausencias: ¿dónde falta información y por qué?

dim(pedidos_bruto)             # ¿1200 pedidos? ¿o más?
n_distinct(pedidos_bruto$id_pedido)   # ¿la clave identifica las filas?

pedidos_bruto |> count(canal)  # categorías realmente presentes
summary(pedidos_bruto)         # rangos y NAs de las numéricas
  • El diccionario de variables (qué mide, en qué unidades, qué valores admite) es parte de los datos: si no viene, hay que pedirlo o reconstruirlo

Problema 1: tipos mal leídos

  • Un número guardado como texto no da error: simplemente no se puede sumar
mean(pedidos_bruto$importe)    # error o NA: es texto
  • Los importes llegan con formato español: "1.234,56". Hay que decirle a R cuál es el separador
pedidos <- pedidos_bruto |>
  mutate(importe = parse_number(importe,
                    locale = locale(decimal_mark = ",", grouping_mark = ".")))
  • El error inverso es peor: un identificador leído como número
pedidos_bruto |> count(cp_envio) |> head(3)   # 3003 en vez de "03003"
  • Un código postal, un NIF o un código de producto no son cantidades: no tiene sentido su media y pierden ceros iniciales. Se leen como texto
pedidos <- pedidos |>
  mutate(cp_envio = str_pad(as.character(cp_envio), width = 5, pad = "0"),
         provincia_cp = str_sub(cp_envio, 1, 2))

Problema 2: fechas

  • Una fecha en texto se ordena alfabéticamente: "04/03/2025" va antes que "12/01/2024"

  • El riesgo real es la ambigüedad: 03/04/2025 es 3 de abril en España y 4 de marzo en EE.UU.

pedidos_bruto |> count(str_detect(fecha, "/"))   # ¿cuántos de cada formato?
  • Con dos formatos mezclados hay que detectar cuál es cada uno, no adivinar
pedidos <- pedidos |>
  mutate(fecha = if_else(str_detect(fecha, "/"),
                         as.Date(fecha, format = "%d/%m/%Y"),
                         as.Date(fecha, format = "%Y-%m-%d")))

sum(is.na(pedidos$fecha))     # comprobación: ninguna fecha sin convertir
  • Comprobar siempre el rango: fechas anteriores al inicio del negocio o posteriores a hoy son errores
range(pedidos$fecha)

Problema 3: categorías con erratas

  • Las mismas categorías escritas de formas distintas se cuentan como distintas: el group_by() del Tema 03 obedece al texto, no a la intención
pedidos |> count(canal)              # "web", "WEB", " web ", ...
vendedores |> count(provincia)       # "Valencia" y "València"
  • Homogeneizar es una decisión: estamos afirmando que dos etiquetas son la misma cosa
pedidos <- pedidos |>
  mutate(canal = str_squish(str_to_lower(canal)),
         canal = parse_factor(canal, levels = c("web", "app", "tienda")))

vendedores <- vendedores |>
  mutate(provincia = str_squish(str_to_title(provincia)),
         provincia = fct_recode(provincia, "Valencia" = "València"))
  • Después, volver a contar: si aparece un nivel NA, es que había una categoría que no estaba en nuestra lista
pedidos |> count(canal)

Problema 4: unidades mezcladas

  • El caso más caro y el más difícil de ver: cada fila está medida en una escala distinta

  • Los vendedores rellenan su facturación anual en un formulario libre: unos ponen euros y otros miles de euros

vendedores |> summarize(min = min(facturacion_anual),
                        mediana = median(facturacion_anual),
                        max = max(facturacion_anual))
  • Se detecta por órdenes de magnitud y, mejor, contrastando con una variable relacionada
ggplot(vendedores, aes(x = facturacion_anual)) +
  geom_histogram(bins = 30) + scale_x_log10()

vendedores |> mutate(fact_por_empleado = facturacion_anual / empleados) |>
  arrange(fact_por_empleado) |> head(5)
  • Una facturación de 200 euros por empleado y año es económicamente imposible: no es un dato extremo, es otra unidad

Problema 5: duplicados

  • Hay dos tipos y no significan lo mismo

    1. Filas idénticas: casi siempre un fallo de exportación o de carga

    2. Clave repetida con contenido distinto: dos versiones del mismo hecho

sum(duplicated(pedidos))                       # filas completas repetidas

pedidos |> count(id_pedido) |> filter(n > 1)   # clave repetida
  • Antes de borrar, hay que mirar las filas en conflicto: en nuestro caso, el mismo pedido aparece con y sin IVA
repetidos <- pedidos |> count(id_pedido) |> filter(n > 1) |> pull(id_pedido)

pedidos |> filter(id_pedido %in% repetidos) |>
  arrange(id_pedido) |> select(id_pedido, fecha, importe)
  • Los 23 registros repetidos son menos del 2% de las filas: parece poco en la tabla, pero infla los ingresos declarados sin que nada falle, y decide un objetivo de ventas o una comisión

Problema 6: dónde falta información

  • Primero, cuántos ausentes hay y en qué variables
pedidos |>
  summarize(across(everything(), ~ sum(is.na(.x)))) |>
  pivot_longer(everything(), names_to = "variable", values_to = "NAs") |>
  filter(NAs > 0) |> arrange(desc(NAs))
  • Segundo, los ausentes disfrazados: códigos centinela que R lee como números válidos
pedidos |> count(valoracion) |> head(4)     # aparece -99
  • Un -99 no declarado contamina toda la descriptiva: entra en la media, en la desviación típica y en el gráfico
pedidos |> summarize(media_mal = mean(valoracion, na.rm = TRUE))

pedidos <- pedidos |>
  mutate(valoracion = na_if(valoracion, -99))

pedidos |> summarize(media_bien = mean(valoracion, na.rm = TRUE))

Herramientas que automatizan la detección

  • Estas funciones recorren todas las variables y ahorran los primeros diez minutos
library(modelsummary)
pedidos |> datasummary_skim()                  # numéricas
pedidos |> datasummary_skim(type = "categorical")

library(skimr)
pedidos |> skim()

library(janitor)
pedidos_bruto |> get_dupes(id_pedido)          # duplicados de clave
  • También existen informes completos automáticos (DataExplorer::create_report(), dlookr) y se pueden pedir a una IA generativa

  • Limitaciones: producen mucho ruido, no conocen el contexto y no distinguen un error de un dato extremo legítimo

  • Regla: la automatización acelera la detección; la decisión sigue siendo del analista, que es quien conoce el negocio

PRÁCTICA en clase (1): auditoría de calidad

Trabajad con pedidos_bruto y vendedores. En 25 minutos, y sin corregir nada todavía, elaborad un informe de una página con:

  1. Número de filas y comprobación de si id_pedido identifica de forma única cada pedido

  2. Lista de variables cuyo tipo NO corresponde a lo que miden, con el problema concreto de cada una

  3. Recuento de las categorías realmente presentes en canal y en provincia, señalando cuáles son la misma cosa

  4. Número de ausentes por variable, distinguiendo los NA de los códigos centinela

  5. Para CADA problema detectado, una frase: qué cifra del informe final quedaría mal si no lo corregimos

Nota

Ahora el ejercicio 1, los cinco apartados: es esta misma auditoría, apartado por apartado.

Parte I (b). Decidir qué hacer

Ausentes: la pregunta no es cuántos, sino por qué

  • Tres mecanismos, con consecuencias muy distintas para el análisis

    1. Ausencia completamente aleatoria: falta por azar (un fallo del servidor un martes). Trabajar con los casos completos reduce la muestra pero no sesga

    2. Ausencia aleatoria condicionada: falta según variables que SÍ observamos (los pedidos por app registran menos el descuento). Corregible si condicionamos en esas variables

    3. Ausencia no aleatoria: falta según el propio valor que no vemos. Aquí no hay arreglo estadístico limpio

  • El tercer caso es el habitual en economía:

    • En una encuesta de presupuestos familiares, quien tiene rentas muy altas responde menos: la renta media observada subestima la real

    • En nuestra encuesta post-compra, el cliente enfadado no contesta: la valoración media mide la satisfacción de los que contestan

  • Ventaja de trabajar con datos simulados: sabemos la verdad. La valoración media real es 7,5 y la observada entre quienes responden se acerca a 7,9. Ninguna limpieza corrige esa diferencia

Ausentes: ¿y qué hacemos?

  • Antes de decidir, distinguir el ausente estructural del ausente por falta de información

    • descuento = NA significa “este pedido no entró en promoción”: el valor correcto es 0, no “desconocido”
pedidos <- pedidos |> mutate(descuento = replace_na(descuento, 0))
  • Para los demás, tres opciones y lo que implica cada una:

    1. Eliminar filas (drop_na()): válido si la ausencia es aleatoria. Si no, se elimina justo el tipo de cliente que interesa

    2. Imputar (media, mediana, o un valor del grupo): mantiene el tamaño muestral pero inventa variación que no existe y reduce artificialmente la dispersión

    3. Indicador de ausencia: crear una variable sin_valoracion y analizarla. No arregla el sesgo, pero lo hace visible

pedidos <- pedidos |> mutate(sin_valoracion = is.na(valoracion))

pedidos |> group_by(sin_valoracion) |>
  summarize(n = n(), importe_medio = mean(importe), tasa_devol = mean(devuelto))
  • Si quienes no contestan son distintos en TODO lo demás, imputar la media es la peor opción

Atípicos: detectar

  • Un valor atípico es el que no encaja en el patrón general. Detectarlo es mecánico
ggplot(pedidos, aes(y = importe)) + geom_boxplot()
ggplot(pedidos, aes(x = importe)) + geom_histogram(bins = 40) + scale_x_log10()
  • El criterio habitual del diagrama de caja: fuera del rango intercuartílico ampliado
lim <- pedidos |>
  summarize(q1 = quantile(importe, 0.25), q3 = quantile(importe, 0.75)) |>
  mutate(iqr = q3 - q1, inf = q1 - 1.5 * iqr, sup = q3 + 1.5 * iqr)

pedidos |> filter(importe > lim$sup) |> count()
  • Aviso: aquí marca 77 pedidos, más del 6% de la muestra. Con distribuciones asimétricas (renta, ventas, tamaño de empresa) este criterio señala como atípica una parte enorme de la cola derecha que es perfectamente normal

Atípicos: decidir

  • La detección no dice qué hacer. Hay que separar tres casos:

    1. Error de registro: un importe 100 veces mayor por una coma mal puesta. Se corrige o se elimina, documentándolo

    2. Observación real y relevante: un pedido mayorista de 4.000 euros. Es el cliente más valioso: eliminarlo destruye la información

    3. Observación real pero de otra población: es un cliente B2B en una tabla pensada para minoristas. Se analiza aparte

pedidos |> filter(importe > lim$sup) |>
  select(id_pedido, canal, unidades, importe) |> arrange(desc(importe))
  • El contraste con otras variables distingue el error del caso real: 4.729 euros con 3 unidades no es un cliente, es una coma mal puesta; 4.974 euros con 90 unidades es un mayorista

  • En economía, las colas son el fenómeno: unas pocas empresas concentran la mayor parte de las exportaciones o del empleo. Recortar la cola por comodidad estadística elimina el objeto de estudio

  • Alternativas menos destructivas: analizar en logaritmos, usar la mediana, o acotar (winsorizar) declarándolo

Unidades y duplicados: decidir con un criterio explícito

  • La corrección de unidades necesita una regla, no un ojo clínico
vendedores <- vendedores |>
  mutate(
    # regla: una facturación por empleado por debajo de 5.000 euros al año
    # es imposible en este sector; esa fila estaba declarada en miles
    fact_empleado = facturacion_anual / empleados,
    facturacion_eur = if_else(fact_empleado < 5000,
                              facturacion_anual * 1000, facturacion_anual)
  )

vendedores |> summarize(min = min(facturacion_eur / empleados),
                        max = max(facturacion_eur / empleados))
  • El umbral es un supuesto: hay que escribirlo, y comprobar que ningún caso queda cerca del corte

  • Con los duplicados, la regla depende de la pregunta: si el conflicto es “con IVA” frente a “sin IVA”, quedarse con el menor no es limpiar, es elegir la definición del ingreso

pedidos <- pedidos |> distinct()   # solo elimina filas IDÉNTICAS

Un flujo de limpieza reproducible

  • Los datos en bruto no se tocan nunca: la limpieza es un script que va del fichero original al fichero de trabajo
pedidos_limpio <- pedidos_bruto |>
  # 1. tipos
  mutate(importe  = parse_number(importe,
                      locale = locale(decimal_mark = ",", grouping_mark = ".")),
         cp_envio = str_pad(as.character(cp_envio), 5, pad = "0"),
         fecha    = if_else(str_detect(fecha, "/"),
                            as.Date(fecha, format = "%d/%m/%Y"),
                            as.Date(fecha, format = "%Y-%m-%d"))) |>
  # 2. categorías
  mutate(canal = parse_factor(str_squish(str_to_lower(canal)),
                              levels = c("web", "app", "tienda"))) |>
  # 3. ausentes: centinela y estructural
  mutate(valoracion = na_if(valoracion, -99),
         descuento  = replace_na(descuento, 0)) |>
  # 4. duplicados exactos
  distinct()
  • Cada bloque lleva un comentario con la decisión y su motivo; el número de filas se comprueba antes y después
nrow(pedidos_bruto); nrow(pedidos_limpio)

PRÁCTICA en clase (2): limpiar y justificar

Sobre la auditoría de la práctica anterior, en 30 minutos:

  1. Escribid un único bloque de limpieza que corrija tipos, categorías y ausentes centinela, y elimine los duplicados exactos

  2. Comprobad el resultado: filas antes y después, tipos, recuento de canal y rango de fecha

  3. Calculad los ingresos totales y el ticket medio antes y después de eliminar los duplicados exactos. ¿En cuántos euros y en qué porcentaje cambia la cifra que iría al informe?

  4. Decidid qué hacer con los 8 pedidos de clave repetida y justificad la decisión en dos frases

  5. Elegid un pedido atípico y argumentad si es error, cliente mayorista u otra población, usando unidades como apoyo

Nota

Ahora el ejercicio 2. Los apartados a) a e) son esta práctica; f) y g) van un paso más allá, con la columna que viene dada y con la identidad que tiene que cumplirse.

Parte II. Descriptiva con propósito

No describir por describir

  • Un informe con veinte gráficos y ninguna conclusión es un informe fallido

  • El orden correcto es siempre el mismo:

    pregunta económica → magnitud que la responde → estadístico o gráfico → lectura

  • Ejemplos con nuestro caso:

Pregunta de dirección Magnitud Herramienta
¿Cuánto gasta un cliente típico? centro de una distribución asimétrica mediana y cuartiles
¿La app canibaliza la web o amplía el mercado? gasto por cliente y solapamiento tabla por canal y por cliente
¿Qué vendedores son más productivos? ventas por empleado ratio + dispersión por sector
¿Crecemos? variación interanual serie mensual y tasas
  • Si no sabéis qué decisión cambia con el resultado, el estadístico sobra

Elegir el centro: media o mediana

  • Con distribuciones asimétricas, la media y la mediana responden a preguntas distintas
pedidos |> summarize(media = mean(importe), mediana = median(importe),
                     p90 = quantile(importe, 0.90), max = max(importe))
  • La media responde a “cuánto factura la empresa por pedido”: es lo que hay que usar para presupuestar, porque media x n = total

  • La mediana responde a “cuánto gasta un cliente típico”: no la mueven unos pocos pedidos mayoristas

  • Con renta, salarios, ventas o tamaño de empresa, la media siempre está por encima de la mediana. El INE publica ambas por eso mismo

  • Un solo número nunca describe una distribución asimétrica: acompañadlo de cuartiles o de un gráfico

ggplot(pedidos, aes(x = importe)) + geom_histogram(bins = 40) + scale_x_log10()

Elegir el gráfico que responde a la pregunta

La visualización se vio en el Tema 02: aquí solo elegimos, según lo que se quiera mostrar.

  • Una distribución: histograma o densidad (forma, asimetría, acumulaciones raras)

  • Comparar distribuciones entre grupos: diagramas de caja o densidades superpuestas

  • Relación entre dos cantidades: dispersión (con logaritmos si hay asimetría)

  • Evolución en el tiempo: línea, nunca barras

  • Composición o reparto: barras apiladas al 100%

Y antes de cualquier comparación por grupos: cuántas observaciones hay en cada grupo. Una media sobre 7 pedidos no se compara con una sobre 700.

ggplot(pedidos, aes(x = canal, y = importe)) +
  geom_boxplot() + scale_y_log10()

pedidos |> count(canal, devuelto) |>
  ggplot(aes(x = canal, y = n, fill = devuelto)) +
  geom_col(position = "fill")

Importante

Hasta ahora geom_bar() contaba él solo las observaciones de cada categoría. Aquí no: la altura de cada barra ya la hemos calculado nosotros (con count()), así que hay que decirle que no vuelva a contar y dibuje el valor tal cual. Eso es geom_bar(stat = "identity"), y geom_col() es su atajo: exactamente lo mismo, escrito más corto.

:::

Parte II (b). KPIs y sus trampas

Qué es un buen indicador (KPI)

  • Un KPI es un número que resume el estado del negocio y sobre el que alguien decide

  • Para que sirva necesita cuatro cosas, y las cuatro se olvidan a menudo:

    1. Definición escrita: ¿“cliente activo” es quien compró alguna vez, en el último año o en el último mes?

    2. Denominador explícito: por pedido, por cliente, por empleado, por euro invertido

    3. Frecuencia y periodo de comparación: mensual frente al mes anterior no es lo mismo que frente al mismo mes del año pasado

    4. Responsable: quién puede actuar sobre él

kpis <- pedidos |>
  summarize(pedidos          = n(),
            ingresos         = sum(importe),
            ticket_mediano   = median(importe),
            clientes_activos = n_distinct(id_cliente),
            tasa_devolucion  = mean(devuelto))
  • Un KPI mal definido no es un KPI impreciso: es un incentivo mal puesto. Si se premia el número de pedidos, aparecerán pedidos partidos en dos

Trampa 1: niveles y tasas

  • El nivel y la tasa responden a preguntas distintas y con frecuencia se contradicen
ventas_prov |>
  mutate(variacion_abs = ventas_2025 - ventas_2024,
         variacion_pct = (ventas_2025 / ventas_2024 - 1) * 100) |>
  arrange(desc(variacion_pct))
provincia ventas 2024 ventas 2025 variación absoluta variación %
Teruel 4.000 10.000 +6.000 +150,0%
Alicante 412.000 445.000 +33.000 +8,0%
Valencia 380.000 399.000 +19.000 +5,0%
Madrid 915.000 951.000 +36.000 +3,9%
  • Teruel encabeza el crecimiento porcentual y aporta el 6% del aumento total. Madrid es el último en porcentaje y el primero en euros

  • Un porcentaje sobre una base pequeña es ruido: mostrad siempre el nivel al lado, y no calculéis tasas con denominadores minúsculos

Trampa 2: promedios de promedios

  • La media de unas medias solo coincide con la media global si todos los grupos tienen el mismo tamaño
por_vendedor <- pedidos |> group_by(id_vendedor) |>
  summarize(ticket_medio = mean(importe), n = n())

por_vendedor |> summarize(media_de_medias = mean(ticket_medio))
pedidos |> summarize(media_global = mean(importe))
  • La media de medias da a un vendedor con 3 pedidos el mismo peso que a otro con 90

  • Para agregar tasas hay que ponderar por el denominador, o mejor: sumar numeradores y denominadores

pedidos |> summarize(tasa_correcta = sum(devuelto) / n())

pedidos |> group_by(canal) |> summarize(tasa = mean(devuelto)) |>
  summarize(tasa_incorrecta = mean(tasa))
  • Lo mismo ocurre con la tasa de paro nacional: no es la media de las tasas de las comunidades, sino el cociente de la suma de parados entre la suma de activos

Trampa 3: la agregación puede invertir el resultado

  • Tasa de devolución por canal y categoría de producto en MercadoLocal:
categoría canal pedidos devueltos tasa
Electrónica web 200 20 10,0%
Electrónica app 800 96 12,0%
Textil web 800 240 30,0%
Textil app 200 64 32,0%
TOTAL web 1.000 260 26,0%
TOTAL app 1.000 160 16,0%
  • La app devuelve más en cada categoría (12 frente a 10, y 32 frente a 30), pero menos en el total (16 frente a 26)

  • No hay ningún error de cálculo: es la paradoja de Simpson

devoluciones |> mutate(tasa = devueltos / pedidos)

devoluciones |> group_by(canal) |>
  summarize(tasa_agregada = sum(devueltos) / sum(pedidos))

Trampa 3 (cont.): por qué pasa y qué hacer

  • La causa es la composición: la app vende sobre todo electrónica (poco devuelta) y la web sobre todo textil (muy devuelta). El total mezcla el efecto del canal con el efecto del producto

  • Qué hacer:

    1. Comparar dentro de grupos homogéneos, no en el agregado

    2. Si hace falta un número único, ponderar todos los canales con la misma composición de producto

    3. Preguntarse siempre qué variable puede estar detrás del reparto

  • Esto no es una curiosidad estadística: es el problema central del análisis con datos observacionales

    • El salario medio subió durante la crisis de 2008-2013 en varios países mientras casi nadie tenía subidas: los empleos destruidos fueron los de menor salario, y eso elevó la media de los que quedaron

    • Una comparación de rentabilidad entre sectores sin controlar el tamaño de las empresas mide tamaño, no sector

  • El Tema 05 formaliza esto: comparar “todo lo demás igual” es lo que hace una regresión

Trampa 4: comparar sin base ni contrafactual

  • Una variación no significa nada sin saber respecto a qué

    • Efecto base: crecer un 20% en 2021 tras caer un 22% en 2020 no es un récord, es recuperar parte de lo perdido

    • Elección del periodo: casi cualquier tendencia se puede fabricar eligiendo el punto de partida

    • Ausencia de contrafactual: las ventas suben un 15% tras una campaña, pero también subieron un 14% en los comercios sin campaña

# "las ventas han crecido un 65% en 2025": de enero a diciembre
serie_ventas |>
  filter(mes >= as.Date("2025-01-01")) |>
  summarize(crecimiento = last(ventas) / first(ventas) - 1)
  • Ese 65% no mide crecimiento: mide la distancia entre el mes más flojo y la campaña de Navidad

  • Antes de atribuir un cambio a una causa, comprobad: ¿qué habría pasado sin la intervención? ¿hay un grupo de comparación?

  • Y una regla práctica para informes: mostrar la serie completa, no dos puntos elegidos

Trampa 5: estacionalidad

  • Muchas series económicas tienen un patrón que se repite cada año: comercio en diciembre, hostelería en verano, empleo agrario por campañas
ggplot(serie_ventas, aes(x = mes, y = ventas)) + geom_line()
  • Comparar diciembre con noviembre mide la Navidad, no el negocio
serie_ventas |>
  mutate(var_mensual   = ventas / lag(ventas, 1)  - 1,
         var_interanual = ventas / lag(ventas, 12) - 1) |>
  filter(!is.na(var_interanual))
  • Tres formas correctas de mirar una serie estacional:

    1. Variación interanual: mismo mes del año anterior (lo más simple y lo más habitual)

    2. Media móvil de 12 meses: elimina el patrón y deja la tendencia

    3. Serie desestacionalizada: la publican las fuentes oficiales (INE, Eurostat) ya calculada

  • Y no comparéis nunca una tasa mensual con una interanual en el mismo cuadro sin decirlo

PRÁCTICA en clase (3): un cuadro de mando honesto

Dirección pide cinco indicadores para la reunión mensual. En 30 minutos, sobre pedidos_limpio y serie_ventas:

  1. Elegid los cinco KPI y escribid la definición exacta de cada uno (numerador, denominador, periodo)

  2. Calculadlos con summarize() y presentadlos en una tabla

  3. Para el gasto por cliente, decidid si dais media o mediana y justificadlo con la forma de la distribución

  4. Añadid la variación interanual de ventas del último mes, no la mensual, y explicad en una frase por qué

  5. Escribid al pie del cuadro una advertencia sobre la trampa que más amenaza a vuestros indicadores (composición, base pequeña, estacionalidad o selección en las valoraciones)

Nota

Ahora el ejercicio 3. Los apartados a), b), c), g) y h) son esta práctica; d), e) y f) son las tres trampas de estas transparencias: los promedios de promedios, la agregación que invierte el resultado y la base pequeña.

Un caso con datos reales: el INE

La pregunta económica

  • Hasta aquí, datos simulados. Ahora una pregunta que solo se puede responder con datos oficiales:

    ¿Ha subido la cesta de la compra más que el resto de los precios?

  • Es la pregunta que hay detrás de la percepción de encarecimiento: el índice general puede crecer moderadamente mientras alimentación crece mucho más

  • El Instituto Nacional de Estadística publica el IPC mensual, con el índice general y el de cada grupo de gasto

  • Qué necesitamos para responder:

    1. La serie del índice general y la de alimentos y bebidas no alcohólicas

    2. Sus variaciones anuales, no los niveles: un índice base 100 no es un precio

    3. Un periodo suficientemente largo para no confundir un mes raro con una tendencia

  • Y una advertencia de la Parte I que aquí es literal: el IPC es una media ponderada de precios con una cesta fija. Es un promedio, con todas las trampas de los promedios

La API del INE: esqueleto de la descarga

  • El INE ofrece un servicio web que devuelve los datos de una tabla en formato JSON. La petición tiene esta forma:
https://servicios.ine.es/wstempus/js/ES/DATOS_TABLA/{id_tabla}?nult={n}
  • {id_tabla} es el identificador de la tabla publicada en INEbase, y nult el número de periodos más recientes que se piden
library(jsonlite)

# Tabla 50902: IPC nacional, índice general y grupos ECOICOP
url_ipc <- "https://servicios.ine.es/wstempus/js/ES/DATOS_TABLA/50902?nult=36"
ipc_json <- fromJSON(url_ipc, simplifyVector = FALSE)

# Tabla 65335: tasa de paro por sexo y comunidad autonoma, trimestral
# (identificador comprobado en INEbase). Otras utiles: 74999 y 14506
# (paro por grupos de edad y por comunidad).
# url_epa <- "https://servicios.ine.es/wstempus/js/ES/DATOS_TABLA/65335?nult=20"
  • Existe además un paquete de R que envuelve esta API (ineapir): consultad su documentación antes de usarlo en un trabajo

  • Regla del curso: nunca copiéis un identificador de tabla sin comprobar en la web del INE qué contiene exactamente

Del JSON a una tabla ordenada

  • La respuesta es una lista de series; cada serie tiene un nombre y sus observaciones. Hay que dejarla como los datos ordenados del Tema 03
ipc <- tibble(serie = map_chr(ipc_json, "Nombre"),
              datos = map(ipc_json, "Data")) |>
  filter(str_detect(serie, "Variación anual"),
         str_detect(serie, "Índice general|Alimentos")) |>
  unnest_longer(datos) |>
  mutate(anio  = map_int(datos, "Anyo"),
         mes   = map_int(datos, "FK_Periodo"),
         fecha = as.Date(sprintf("%d-%02d-01", anio, mes)),
         valor = map_dbl(datos, "Valor")) |>
  select(serie, fecha, valor) |> arrange(serie, fecha)
  • La fecha se construye con el año y el periodo que vienen etiquetados. El campo Fecha trae milisegundos desde 1970 en hora local: convertirlo suponiendo UTC desplaza cada observación un mes atrás, y el error es silencioso

  • Y entonces, y solo entonces, se responde a la pregunta

ggplot(ipc, aes(x = fecha, y = valor, color = serie)) +
  geom_line() + geom_hline(yintercept = 0)

Qué comprobar antes de creerse el resultado

  • Un dato oficial también exige AED. Antes de publicar el gráfico:

    1. Cuántas series han llegado y si son las que pedimos: el filtro por nombre es texto, y el texto cambia entre publicaciones

    2. Ausencias y huecos: ¿faltan meses? ¿hay valores provisionales?

    3. Base del índice: los índices se rebasan cada cierto tiempo; comparar niveles de bases distintas no tiene sentido, comparar variaciones sí

    4. Ponderaciones: la cesta del IPC se actualiza cada año, así que el índice de 2019 y el de 2025 no pesan lo mismo los mismos bienes

  • Y la lectura económica, que es lo que se pide en el informe:

    • Si alimentación crece por encima del general, el encarecimiento golpea más a los hogares de renta baja, porque dedican una proporción mayor de su gasto a alimentación

    • Ese es un resultado distributivo que un índice medio, por sí solo, esconde

Microdatos: los datos de los que salen las tablas

Las tablas publicadas salen de aquí

  • El INE publica cientos de tablas, pero son un menú cerrado: alguien decidió qué cruces salen. Paro por sexo y comunidad, sí. Paro según la situación laboral de la pareja, no

  • Todas esas tablas se calculan a partir de la misma cosa: un fichero con una fila por persona entrevistada, los microdatos

  • Con ese fichero podéis hacer tres cosas, y solo la primera la da la tabla:

    1. Reproducir cualquier tabla publicada

    2. Construir el agregado que nadie publicó, porque a nadie se le ocurrió cruzar esas dos variables

    3. Estimar modelos a nivel individual: una regresión, una clasificación. Eso una tabla agregada no lo permite nunca, y es la mitad que queda del curso

  • El precio: los conceptos (activo, ocupado, parado) NO vienen calculados. Los construís vosotros, y ahí es donde se rompe todo

Bajároslo vosotros es parte del trabajo

  • Los microdatos de la Encuesta de Población Activa están en el ftp del INE, abiertos y sin registro:
https://www.ine.es/ftp/microdatos/epa/datos_2t26.zip     # 21 MB, segundo trimestre de 2026
https://www.ine.es/ftp/microdatos/epa/dr_EPA_2026.xlsx   # diseño de registro
  • El patrón del nombre es datos_<trimestre>t<año con dos dígitos>.zip. Y el diseño de registro es obligatorio: es el documento que dice qué significa cada columna y cada código
dir.create("~/datos-cache/epa", recursive = TRUE, showWarnings = FALSE)

destino <- "~/datos-cache/epa/datos_2t26.zip"
if (!file.exists(destino))                                  # solo la primera vez
  download.file("https://www.ine.es/ftp/microdatos/epa/datos_2t26.zip", destino)

unzip(destino, exdir = "~/datos-cache/epa")
  • Fijaos dónde va: en una carpeta de caché fuera del proyecto. Los ficheros de datos grandes NO se meten en el repositorio ni en una carpeta sincronizada. En el repositorio va el código que los descarga

Dentro del zip hay cuatro formatos: elegid a conciencia

  • El mismo zip trae los datos en ancho fijo (.txt), en texto separado por tabuladores, en un .RData y en parquet, más el diccionario en JSON

  • En clase leemos el texto separado, y no es por gusto:

    • Es lo que os vais a encontrar fuera: la mayoría de las fuentes no os regalan un .RData

    • Obliga a mirar los tipos, que es medio análisis exploratorio

library(tidyverse)
epa <- read_delim("~/datos-cache/epa/CSV/EPA_2026T2.tab", delim = "\t",
                  col_types = cols(.default = col_character()))   # TODO como texto, a propósito
  • Que existan los otros formatos conviene saberlo: load() para el .RData (cómodo pero solo sirve en R) y arrow::read_parquet() para el parquet (el formato que hay que conocer cuando el fichero no cabe en memoria)

  • Quien solo sabe abrir el .RData se queda bloqueado el día que la fuente no lo publique. Y lo leemos como texto porque los códigos del INE son texto: "03" no es el número 3

Qué es una fila y cuánto pesa

  • El fichero del segundo trimestre de 2026 trae 114.021 personas y 94 variables. No hay fichero de hogares aparte: el hogar va dentro de cada fila
Variable Qué es
NVIVI, NPERS identificador de vivienda y orden de la persona dentro de ella (47.914 hogares)
RELPP1, NCONY relación con la persona de referencia y orden de su cónyuge
AOI situación laboral codificada (la variable clave)
FACTOREL factor de elevación: a cuántas personas de la población representa esta fila
  • Una fila NO es “una persona más” en la media: representa a un número distinto de personas según su probabilidad de haber sido elegida

  • Y el nivel de hogar no viene dado: se construye agrupando por NVIVI, que es un group_by() del Tema 03

Primera trampa: los espacios que no se ven

  • AOI está vacía para los menores de 16 años. Pero “vacía” aquí significa dos espacios, no una cadena vacía
epa |> count(AOI)                    # "  ", "03", "04", "05", "06", "07", "08", "09"
sum(epa$AOI == "")                   # 0: NINGUNA fila cumple eso
sum(trimws(epa$AOI) == "")           # 15.850: los menores de 16
  • Quien filtra con AOI != "" cree haber quitado a los menores y no ha quitado a nadie. No hay error ni aviso, y la tasa de actividad sale mal:
Fichero completo Tasa de actividad
filter(AOI != "") 51,22%
filter(trimws(AOI) != "") 59,29%
  • Ocho puntos por dos espacios. Y si leéis el .RData en vez del texto, los blancos pueden llegar como NA: entonces filter los descarta él solo, en silencio, y el error cambia de cara pero sigue ahí

Segunda: las categorías no son las que parecen

  • AOI no es “0 = parado, 1 = ocupado”. Hay que leer el diseño de registro:
Código Significado
03, 04 ocupados (03 son ocupados subempleados)
05, 06 parados (05 busca su primer empleo)
07, 08, 09 inactivos
epa <- epa |> mutate(aoi = trimws(AOI),
                     situacion = case_when(aoi %in% c("03", "04") ~ "ocupado",
                                           aoi %in% c("05", "06") ~ "parado",
                                           aoi %in% c("07", "08", "09") ~ "inactivo",
                                           TRUE ~ "menor de 16"))
  • Quien usa solo el 06 como paro deja fuera a quien busca su primer empleo, que son justo los jóvenes: la tasa nacional baja de 9,873% a 8,757%. Un punto entero de paro por no leer una tabla de códigos de siete líneas

Tercera: sin ponderar no hay tasa, y la prueba es replicar

epa <- epa |> mutate(FACTOREL = as.numeric(FACTOREL))
activos <- epa |> filter(situacion %in% c("ocupado", "parado"))

activos |> summarise(paro_mal  = 100 * mean(situacion == "parado"),
                     paro_bien = 100 * sum(FACTOREL[situacion == "parado"]) / sum(FACTOREL))
Fichero completo Sin ponderar Ponderando
Tasa de paro 9,499% 9,873%
  • La suma de FACTOREL es 49.345.143: la población de 16 y más años. Si al sumar los pesos no sale una cifra reconocible de población, algo está mal

  • El INE publica 9,87% para ese trimestre (tabla 65335). Coincide con nuestro 9,873%: eso es la prueba de que hemos entendido el fichero

  • La regla, y va en serio: primero se replica una cifra publicada y solo después se calcula lo que no está publicado. Si no reproducís lo conocido, nadie tiene por qué creerse lo nuevo

La tabla que nadie publica

  • Ya podemos contestar lo que no está en ninguna tabla: ¿cómo es el paro de una persona según la situación de su pareja?

  • Cada fila trae en NCONY el orden de su cónyuge dentro del hogar, así que hay que unir el fichero consigo mismo

personas <- epa |> mutate(orden = trimws(NPERS), cony = trimws(NCONY))

parejas <- personas |>
  filter(cony != "", cony != "00") |>
  left_join(personas |> select(NVIVI, orden, situacion_pareja = situacion),
            by = c("NVIVI", "cony" = "orden"))
Situación de la pareja Tasa de paro de la persona n
pareja ocupada 6,00% 26.046
pareja parada 21,04% 1.842
  • Tres veces y media más de paro cuando la pareja también está parada. Importa porque el paro se concentra en los mismos hogares: no hay una segunda renta que amortigüe

Y lo que ninguna tabla puede dar: un modelo

  • La tabla anterior es un cruce. Con el microdato podemos preguntar algo que un cruce no responde: ¿sigue ahí esa diferencia una vez descontados sexo, edad y estudios?
modelo <- glm(parado ~ pareja_parada + mujer + superior + factor(edad_g),
              data = activos_con_pareja, family = binomial(), weights = round(FACTOREL / 1000))
  • Con 33.345 personas activas que conviven en pareja: el coeficiente de pareja_parada es 1,40 (razón de probabilidades 4,07), y en versión lineal son 14,5 puntos porcentuales más de probabilidad de estar parado. Tener estudios superiores la reduce (razón de probabilidades 0,45)

  • Esto es exactamente lo que vamos a hacer en los temas siguientes, y solo se puede con datos individuales

  • Dos honestidades obligatorias: es asociación, no causa (quien tiene pareja parada se parece a ella en muchas cosas: sector, comarca, estudios), y los errores estándar de una encuesta con diseño complejo no son los que da glm() sin más

Qué mirar antes de creerse un microdato

  • Una columna vacía no dice por qué está vacía, y la diferencia decide qué hacer con ella:
Variable (fichero completo) Vacía Qué significa
RELLB1, TRAANT 100% relleno del formato: se tiran
OCUPLU1 98,7% no aplicable: solo quien tiene segundo empleo
SITU, PARCO1 56,8% no aplicable: solo los ocupados
  • Tirar OCUPLU1 por “tener demasiados ausentes” es tirar la respuesta a una pregunta que sí se hizo

  • Cuatro reglas si usáis microdatos en el trabajo final:

    1. Leed el diseño de registro antes de escribir una línea de código

    2. Replicad una cifra publicada antes de calcular la vuestra

    3. Comprobad que la suma de los pesos es una población reconocible

    4. El fichero grande se descarga a una caché fuera del repositorio; en el repositorio va el código

Conclusión

Buenas prácticas del AED

En la limpieza

  1. Los datos en bruto no se modifican nunca: la limpieza es un script reproducible

  2. Cada corrección lleva escrito el motivo y la regla aplicada, no solo el código

  3. Contar filas antes y después de cada paso, y comprobar que el cambio es el esperado

  4. Los ausentes y los atípicos se investigan antes de tratarse: por qué faltan, por qué son extremos

En la descriptiva

  1. Primero la pregunta, después el estadístico. Si ninguna decisión cambia, sobra

  2. Con distribuciones asimétricas, nunca un solo número: centro, dispersión y forma

  3. Toda tasa lleva su denominador y su nivel al lado

  4. Antes de comparar grupos, mirar la composición de cada grupo

Errores más caros: convertir con as.numeric() sin mirar, promediar promedios, comparar meses consecutivos en series estacionales y confiar en una media calculada sobre quienes decidieron responder