1  El paradigma de la investigación reproducible

En la ingeniería y la ciencia de datos moderna, existe una crisis silenciosa: la incapacidad de reproducir resultados pasados. Un estudio o modelo predictivo no es válido si solo funciona en el ordenador de quien lo creó.

Para combatir esto, abandonamos las herramientas manuales (“point-and-click”) en favor de flujos de trabajo basados en código.

NotaDefinición: Reproducibilidad vs Replicabilidad

Aunque a veces se usan indistintamente, existe un matiz técnico importante. Hablamos de reproducibilidad cuando un investigador independiente tiene acceso a los mismos datos y al mismo código, y es capaz de computar exactamente los mismos resultados; este es el estándar mínimo de ingeniería que perseguimos. Por otro lado, la replicabilidad es un estándar científico más alto, donde un investigador independiente recoge nuevos datos experimentales bajo las mismas condiciones y llega a las mismas conclusiones científicas. Este libro se centra en garantizar la primera: la reproducibilidad total mediante código robusto y autocontenido.

1.1 Ingeniería de Proyectos de Datos

Antes de escribir una sola línea de código de análisis, un ingeniero debe establecer una infraestructura sólida. El caos en los archivos es la causa #1 de la “muerte” de los proyectos a largo plazo.

1.1.1 Gestión profesional de proyectos

El pecado de las rutas absolutas

Jamás utilice rutas que dependan de su usuario específico:

# MALA PRÁCTICA: Ruta absoluta
# Solo funciona en EL ordenador de "Juan"
datos <- read.csv("C:/Users/Juan/Documentos/Tesis/Analisis_Datos/data.csv")

Si envía este script a su profesor o colega, fallará. Si cambia de ordenador, fallará.

La solución: RStudio Projects (.Rproj)

Un Proyecto de RStudio es un archivo que define la “raíz” de su trabajo. Al abrir el .Rproj, R entiende automáticamente que esa carpeta es el “hogar”, permitiendo usar rutas relativas:

# BUENA PRÁCTICA: Ruta relativa al proyecto
# Funciona en CUALQUIER ordenador (Windows, Mac, Linux)
datos <- read_csv("data/ventas_2024.csv")

Estructura de carpetas estándar

Para mantener el orden, sugerimos una jerarquía estándar que separe claramente los insumos de los productos. En la raíz del proyecto (nombre_proyecto/) se debe ubicar el archivo .Rproj. Los datos originales e inmutables, aquellos que nunca deben editarse manualmente para preservar la integridad de la fuente, se almacenan en data-raw/. Una vez procesados y limpios, los datasets listos para el análisis se guardan en data/. El código ejecutable, ya sean funciones auxiliares o scripts de limpieza, reside en scripts/, mientras que cualquier archivo generado por el código, como gráficos o tablas exportadas, debe dirigirse a output/. Finalmente, los documentos narrativos como este libro se organizan en chapters/.

1.1.2 El control de versiones: Git y GitHub

En ingeniería de software, trabajar sin control de versiones es impensable. En ciencia de datos, debería serlo también. Git es un sistema que registra los cambios en nuestro código a lo largo del tiempo, permitiéndonos volver a versiones anteriores (como un “Deshacer” infinito) y trabajar en equipo sin sobrescribir el trabajo de otros.

El flujo de trabajo en Git se basa en cuatro conceptos fundamentales que operan en ciclo. Todo comienza en el Repositorio (Repo), que es la carpeta de su proyecto vigilada por el sistema. Cuando realizamos cambios significativos, creamos una instantánea del estado del proyecto mediante un Commit; piense en esto como un punto de guardado seguro al que siempre se puede volver. Para colaborar o respaldar el trabajo, enviamos estos commits locales a la nube (GitHub) mediante un Push. Inversamente, para actualizar nuestro entorno local con los cambios que otros hayan subido a la nube, ejecutamos un Pull.

TipGit en RStudio

RStudio tiene una integración excelente con Git. En el panel superior derecho, encontrará la pestaña Git. Desde ahí puede marcar archivos (Stage), hacer Commit y sincronizar con Push/Pull sin tocar la terminal.

1.1.3 Gestión de dependencias con renv

Un script que funciona hoy puede no funcionar mañana si los paquetes se actualizan. renv es un paquete que crea un entorno de librería aislado para cada proyecto.

El uso de renv sigue un ciclo de vida predecible. Primero, se inicializa el proyecto con renv::init(), lo que configura la infraestructura para usar librerías privadas y aisladas del sistema. A partir de ahí, el trabajo continúa normalmente instalando paquetes con install.packages(). La diferencia clave es que, periódicamente, debemos guardar el estado de nuestras dependencias mediante renv::snapshot(), lo cual genera una “lista de ingredientes” exacta (el archivo renv.lock). Si cambiamos de ordenador o un colega necesita ejecutar nuestro código, el comando renv::restore() leerá ese archivo y reconstruirá el entorno instalando exactamente las mismas versiones de los paquetes, garantizando así la reproducibilidad técnica.

1.1.4 Comunicación científica con Quarto

Quarto es la evolución de RMarkdown. Permite mezclar narrativa (texto), código y resultados (gráficos/tablas) en un solo documento vivo.

Anatomía de un documento

Todo archivo .qmd comienza con un encabezado YAML (metadatos) entre tres guiones:


title: "Mi Análisis"
format: html

A continuación, escribimos texto en Markdown (usando **negrita**, # Títulos, etc.) e insertamos Code Chunks para el análisis:

```{r}
#| label: fig-dispersion
#| fig-cap: "Relación entre variables"
plot(x, y)
```

Referencias cruzadas y citas

Quarto gestiona automáticamente la numeración de figuras y ecuaciones, un requisito indispensable en textos académicos.

  • Figuras: Al etiquetar el chunk con #| label: fig-algo, podemos citarlo en el texto escribiendo @fig-algo.
  • Bibliografía: Usando un archivo .bib, podemos citar fuentes con [@autor2024].

1.2 El Taller del Ingeniero: R y RStudio

Ahora que tenemos una metodología de trabajo robusta, entremos en el detalle de las herramientas específicas.

1.2.1 Entorno para ciencia de datos

Para trabajar profesionalmente, distinguimos tres componentes:

  1. El Motor (R): El lenguaje de programación que realiza los cálculos.
  2. El Volante (RStudio): El Entorno de Desarrollo Integrado (IDE) que nos permite gestionar el código cómodamente.
  3. El Ecosistema (tidyverse): Un conjunto de librerías modernas que comparten una gramática y filosofía común.
NotaUna Breve Historia de R

R fue creado en 1993 por Ross Ihaka y Robert Gentleman en la Universidad de Auckland, Nueva Zelanda. Se basaron en el lenguaje S (desarrollado en Bell Labs). El nombre “R” proviene en parte de las iniciales de sus dos creadores y en parte como un juego de palabras sobre el nombre de S. Hoy en día, es un proyecto colaborativo global mantenido por el “R Core Team”.

1.2.2 Los 4 paneles de RStudio

RStudio divide su interfaz en cuatro cuadrantes críticos. Es vital entender qué hace cada uno para no perder datos.

  1. Source (Editor - Arriba Izqda): Su “cuaderno de laboratorio”. Aquí se escriben los scripts (.R) y documentos (.qmd). Lo que se escribe aquí, se guarda.
  2. Console (Abajo Izqda): Donde R ejecuta las órdenes. Advertencia: Es efímero. Si cierra RStudio, lo que haya escrito aquí desaparece. Úselo solo para pruebas rápidas.
  3. Environment (Arriba Dcha): La memoria RAM visible. Muestra las variables y tablas cargadas.
  4. Files/Plots/Packages (Abajo Dcha): Su explorador de archivos y visor de resultados gráficos.
ImportanteConfiguración Profesional

Vaya a Tools > Global Options y asegúrese de desmarcar “Restore .RData into workspace at startup”. Esto obliga a que su código sea capaz de regenerar todo el análisis desde cero cada vez que inicia sesión, garantizando la reproducibilidad.

1.2.3 Fundamentos del lenguaje R

Antes de usar herramientas avanzadas, debemos comprender la sintaxis base.

Asignación y estilo

En R, creamos variables asignando valores con el operador flecha <-.

x <- 10
nombre <- "Modelización"

Guía de estilo

El código se lee más veces de las que se escribe. Siga estas reglas:

  • snake_case: Use minúsculas y guiones bajos (precio_unitario).
  • Nombres descriptivos: Evite x, y, temp. Use tasa_interes, pib_real.
  • Sin espacios: variable nueva dará error. Use variable_nueva.

R maneja fundamentalmente tres tipos de datos atómicos. Los números se representan como tipo Numeric (Double), que abarca tanto enteros como reales (ej. 1.5, 200). El texto se maneja como tipo Character, siendo obligatorio el uso de comillas simples o dobles. Finalmente, el tipo Logical es crucial para el control de flujo y el filtrado de datos, admitiendo únicamente los valores TRUE o FALSE.

Vectores

La estructura mínima en R es el vector. Se crea con la función c() (concatenar).

precios <- c(100, 120, 150)
ciudades <- c("Málaga", "Sevilla", "Granada")

La magia de R es la vectorización: podemos operar sobre todo el vector a la vez sin bucles.

# Aplicar IVA a todos los precios simultáneamente
precios_con_iva <- precios * 1.21

1.3 Ingeniería de Datos con tidyverse

El paquete dplyr (parte de tidyverse) proporciona una “gramática” consistente para la manipulación de datos. Se basa en verbos que realizan acciones humanas sobre los datos.

NotaEl Arquitecto del Tidyverse

Hadley Wickham, Estadístico Jefe en Posit, revolucionó R al crear paquetes como ggplot2 y dplyr. Su filosofía se basa en que “los datos ordenados (tidy data) son todos iguales, pero cada conjunto de datos desordenado lo es a su manera”.

El tidyverse es impulsado en gran medida por Hadley Wickham. Su idea central es que las herramientas de manipulación de datos deben seguir una “gramática”. Así como una gramática lingüística nos permite combinar palabras para crear frases complejas, la “gramática de manipulación de datos” (implementada en dplyr) nos da verbos que se pueden combinar para construir análisis complejos de forma legible.

Cargaremos el paquete y el dataset de ejemplo:

library(tidyverse)
data("starwars") # Dataset incluido en dplyr

1.3.1 El operador tubería (the pipe |>)

La tubería nos permite leer el código de izquierda a derecha, encadenando operaciones. Pasa el resultado de la izquierda como primer argumento de la función de la derecha.

# Lógica: Datos -> Filtrar -> Seleccionar -> Mostrar
starwars |> 
  filter(species == "Droid") |> 
  select(name, homeworld)

1.3.2 Verbo 1: select() (selección de columnas)

Permite escoger, renombrar y reordenar columnas. Es más potente de lo que parece gracias a los ayudantes de selección (selection helpers).

  • Selección básica: select(nombre, altura, peso)
  • Exclusión: select(-nombre) (Todas menos nombre).
  • Rangos: select(name:mass) (Desde name hasta mass).

Las funciones auxiliares o “wildcards” nos permiten seleccionar columnas basándonos en patrones de texto en lugar de nombres exactos. Por ejemplo, starts_with("color") y ends_with("color") seleccionarán todas las variables que comiencen o terminen con ese prefijo/sufijo (como hair_color, skin_color). contains("_") buscará cualquier coincidencia parcial. Quizás la más potente sea where(is.numeric), que selecciona columnas basándose en su tipo de dato, permitiendo operaciones masivas sobre todas las variables numéricas del dataset.

# Ejemplo avanzado
starwars |> 
  select(name, where(is.numeric)) |> 
  head(3)

1.3.3 Verbo 2: filter() (selección de filas)

Filtra las observaciones que cumplen una condición lógica. Si la condición es TRUE, la fila se queda; si es FALSE o NA, se va.

Las condiciones se construyen utilizando operadores lógicos estándar. Para la igualdad exacta usamos == y para la desigualdad !=. Las comparaciones numéricas se realizan con los operadores clásicos >, <, =>, =<. Para combinar múltiples condiciones, el operador & (AND) exige que se cumplan ambas, mientras que | (OR) requiere que se cumpla al menos una. Un operador extremadamente útil en ciencia de datos es %in%, que verifica la pertenencia a un conjunto (por ejemplo, pais %in% c("España", "Francia")).

# Filtrado complejo
starwars |> 
  filter(
    species == "Human",                 # Humanos
    height > 180,                       # Altos
    (homeworld == "Tatooine" | homeworld == "Naboo") # De estos planetas
  )

# Versión elegante con %in%
starwars |> 
  filter(homeworld %in% c("Tatooine", "Naboo", "Alderaan"))
AdvertenciaEl Peligro de NA

filter() elimina los NA automáticamente. Si desea conservarlos, debe pedirlo explícitamente: filter(is.na(mass) | mass > 100).

1.3.4 Verbo 3: arrange() (ordenación)

Ordena las filas. Por defecto es ascendente. Use desc() para descendente.

starwars |> 
  arrange(desc(mass), height) # Ordena por masa (desc) y desempata por altura (asc)

1.3.5 Verbo 4: mutate() (creación/edición de variables)

Crea nuevas columnas o modifica las existentes manteniendo el mismo número de filas.

Usos Avanzados:

  1. Matemáticas: mutate(imc = mass / (height/100)^2)
  2. Lógica Condicional (if_else):
mutate(tipo = if_else(mass > 100, "Pesado", "Ligero"))
  1. Múltiples Casos (case_when): La alternativa vectorizada a los if-else anidados.
starwars |> 
  select(name, height) |> 
  mutate(categoria_altura = case_when(
    height < 100 ~ "Pequeño",
    height < 180 ~ "Mediano",
    height >= 180 ~ "Alto",
    TRUE ~ "Desconocido" # Captura los NA o restos
  ))

1.3.6 Verbo 5: summarise() y group_by() (agregación)

Esta pareja es el motor del análisis de datos (estrategia “Split-Apply-Combine”).

  1. group_by(): No cambia los datos visualmente, pero añade “etiquetas” invisibles de grupo.
  2. summarise(): Colapsa cada grupo en una sola fila calculando estadísticos.

Dentro de summarise, podemos utilizar cualquier función que tome un vector y devuelva un solo valor. Las más comunes incluyen medidas de tendencia central y dispersión como mean(), median(), sd(), min() y max(). Para contar frecuencias usamos n() (número de filas en el grupo) y n_distinct() (número de valores únicos). También son útiles first() y last() para extraer valores posicionales específicos.

# Análisis completo por especie
starwars |> 
  group_by(species) |> 
  summarise(
    total_personajes = n(),
    altura_media = mean(height, na.rm = TRUE),
    peso_maximo = max(mass, na.rm = TRUE),
    planetas_unicos = n_distinct(homeworld)
  ) |> 
  filter(total_personajes > 1) |> # Descartamos especies únicas
  arrange(desc(altura_media))
TipDesagrupar

El resultado de un summarise() suele eliminar la última capa de agrupación, pero es buena práctica añadir .groups = "drop" o usar ungroup() al final para evitar comportamientos inesperados en análisis posteriores.

1.3.7 Caso de Estudio: Pipeline Completo de Ingeniería de Datos

Para visualizar el poder del tidyverse en un contexto real, imaginemos que recibimos datos “sucios” y necesitamos generar un reporte limpio. Simularemos este escenario usando el dataset starwars y una tabla auxiliar ficticia de transacciones.

El Problema: Queremos analizar el “Índice de Masa Corporal (IMC) promedio por especie” y cruzarlo con una tabla de “costes de mantenimiento médico” por especie, para identificar las especies más costosas de mantener.

Paso 0: Preparar los datos (Simulación) Creamos una tabla auxiliar de costes médicos.

# Tabla simulada de costes médicos por especie
costes_medicos <- tibble(
  species = c("Human", "Droid", "Wookiee", "Yoda's species"),
  coste_anual = c(500, 100, 200, 5000)
)

La Pipeline de Ingeniería:

  1. Limpieza: Eliminamos personajes sin masa o altura.
  2. Transformación: Calculamos el IMC.
  3. Agregación: Calculamos el IMC medio por especie.
  4. Enriquecimiento (Join): Cruzamos con la tabla de costes médicos.
  5. Resultado: Ordenamos por coste.
reporte_final <- starwars |> 
  # 1. Limpieza preliminar
  select(name, species, height, mass) |> 
  filter(!is.na(height), !is.na(mass), !is.na(species)) |> 
  
  # 2. Feature Engineering (Crear variable IMC)
  mutate(
    height_m = height / 100,
    imc = mass / (height_m^2)
  ) |> 
  
  # 3. Agregación (Reducir dimensionalidad)
  group_by(species) |> 
  summarise(
    n_sujetos = n(),
    imc_medio = mean(imc),
    peso_total = sum(mass)
  ) |> 
  ungroup() |> 
  
  # 4. Enriquecimiento de datos (Left Join)
  # Unimos nuestra tabla calculada con la tabla externa de costes
  left_join(costes_medicos, by = "species") |> 
  
  # 5. Limpieza final de NAs generados por el join
  # (Si no hay dato de coste, asumimos 0 o un valor base)
  mutate(coste_anual = replace_na(coste_anual, 0)) |> 
  
  # 6. Ordenación para reporte
  arrange(desc(coste_anual))

# Visualizar el resultado
print(reporte_final)
# A tibble: 31 × 5
   species        n_sujetos imc_medio peso_total coste_anual
   <chr>              <int>     <dbl>      <dbl>       <dbl>
 1 Yoda's species         1      39.0        17         5000
 2 Human                 20      24.9      1626.         500
 3 Wookiee                2      23.2       248          200
 4 Droid                  4      32.7       279          100
 5 Aleena                 1      24.0        15            0
 6 Besalisk               1      26.0       102            0
 7 Cerean                 1      20.9        82            0
 8 Clawdite               1      19.5        55            0
 9 Dug                    1      31.9        40            0
10 Ewok                   1      25.8        20            0
# ℹ 21 more rows

Este bloque de código representa el 80% del trabajo diario de un científico de datos: tomar datos crudos, limpiarlos, transformarlos y enriquecerlos para el análisis final.

1.3.8 Verbo extra: slice() y sus variantes

Existen variantes de slice para necesidades específicas: slice(1:5) selecciona filas por su índice posicional; slice_max(mass, n = 3) extrae las filas con los valores más altos de una variable; y slice_sample(n = 5) realiza un muestreo aleatorio de filas, útil para bootstrapping o revisiones rápidas.

1.4 Programación funcional con purrr

Hasta ahora hemos manipulado dataframes. Pero a menudo necesitamos realizar la misma operación sobre múltiples objetos (ej. leer 50 archivos Excel, generar 100 gráficos, entrenar modelos con distintos parámetros).

Los bucles for en R son conocidos por ser verbosos y, si no se gestionan bien, lentos. El paquete purrr (también del tidyverse) nos ofrece la familia de funciones map().

La función básica es map(.x, .f), donde .x es un vector o lista y .f es la función a aplicar a cada elemento.

La familia de funciones map varía según el tipo de salida deseada. La función base map() devuelve siempre una lista, lo cual es la estructura más flexible pero a veces difícil de manejar. Si sabemos que el resultado debe ser un vector numérico, usamos map_dbl() (double); si esperamos texto, map_chr(). Para ingeniería de datos, la variante más útil suele ser map_df(), que intenta combinar los resultados de cada iteración en un único dataframe rectangular.

# Ejemplo: Calcular la media de cada columna numérica de 'starwars'
starwars |> 
  select(where(is.numeric)) |> 
  map_dbl(mean, na.rm = TRUE)

Lectura masiva de archivos

El uso más potente de purrr en ingeniería de datos es la carga masiva. Supongamos que tenemos una carpeta con archivos de ventas mensuales (ventas_ene.csv, ventas_feb.csv, …).

# código conceptual
list.files("data/ventas", pattern = "*.csv", full.names = TRUE) |> 
  map_df(read_csv) 
# ¡En una línea hemos leído y unido todos los archivos!

1.5 Ejercicios propuestos

Para consolidar el flujo de trabajo reproducible, realice los siguientes ejercicios.

Ejercicio 1.1 Configuración de un proyecto.

  1. Cree un nuevo proyecto llamado Practica_Reproducible.
  2. Cree las carpetas data y scripts.
  3. Guarde un script que lea un archivo CSV ficticio usando una ruta relativa.
  4. Mueva la carpeta del proyecto a otra ubicación y verifique que sigue funcionando.

Ejercicio 1.2 Usando el dataset starwars:

  1. Filtre los personajes que no tienen color de piel (skin_color) definido (es decir, NA).
  2. Agrupe por homeworld (planeta de origen).
  3. Calcule la masa media de los habitantes de cada planeta.
  4. Ordene el resultado de mayor a menor masa media.

Ejercicio 1.3 Dado el vector de crecimiento: x <- c(2.5, -1.0, 0.5, -0.2, 3.0).

  1. Cree un vector lógico que sea TRUE cuando el crecimiento sea negativo.
  2. Use ese vector lógico para extraer los valores numéricos de los periodos de recesión.