recipes: preprocesado sin fugas de datos

r
machine-learning
tidymodels
La regla de oro del preprocesado: fit en train, aplica en test. Steps comunes (normalize, dummy, impute), prep() vs bake() vs juice(), y por qué el orden de los pasos importa.

Por qué un pipeline de preprocesado

Casi cualquier modelo necesita transformaciones previas a los datos crudos:

  • Normalizar variables numéricas a media 0 y varianza 1.
  • Codificar variables categóricas como dummies o factores.
  • Imputar valores faltantes.
  • Crear interacciones o términos polinómicos.
  • Eliminar variables con varianza casi cero.

Hacer estos pasos a mano funciona, pero introduce un riesgo crítico llamado fuga de datos.

La regla de oro: fit en train, aplica en test

Imagina que normalizas tus variables usando la media y SD calculadas sobre el dataset completo (train + test). El modelo aprende de un train donde la media incluye información de test. La evaluación final mira un test que el modelo “ya conoce un poco”. La métrica resultante es optimista, el modelo no funcionará así de bien con datos verdaderamente nuevos.

Esto es fuga de datos (data leakage). Es invisible: tu código compila, las métricas parecen razonables, los gráficos diagnósticos no detectan nada. Pero en producción el modelo decepciona.

La regla correcta:

Cualquier estadístico usado en preprocesado (media, SD, mediana, vocabulario de categorías, valores de imputación) debe calcularse SOLO sobre el train set, y aplicarse al test.

recipes automatiza exactamente esto. Una receta es una declaración de los pasos a hacer, no una ejecución inmediata. Tú dices “normaliza los predictores numéricos”, recipes calcula la media y SD del train cuando entrenas, y las reutiliza al aplicar al test.

Forma básica: declarar la receta

library(tidymodels)
data(diamonds, package = "ggplot2")

set.seed(123)
split <- initial_split(diamonds, prop = 0.8)
train <- training(split)
test  <- testing(split)

# Declaración de la receta
receta <- recipe(price ~ ., data = train) |>
  step_log(price, base = 10) |>
  step_normalize(all_numeric_predictors()) |>
  step_dummy(all_nominal_predictors())

Lectura:

  • recipe(price ~ ., data = train): la fórmula declara price como respuesta y todo lo demás como predictores. data = train proporciona el esquema (nombres de columnas, tipos), NO los valores que se van a usar para fit (eso pasa después).
  • step_log(price, base = 10) transforma la respuesta logarítmicamente.
  • step_normalize(all_numeric_predictors()) estandariza todos los predictores numéricos.
  • step_dummy(all_nominal_predictors()) convierte factores en variables dummy.

all_numeric_predictors(), all_nominal_predictors(), all_outcomes(), all_predictors() son selectores que aplican el step a un subconjunto de columnas. Son la forma idiomática de evitar listar columnas a mano.

Steps comunes

Step Para qué
step_normalize() Media 0, SD 1
step_scale() Solo SD 1
step_center() Solo media 0
step_log(), step_sqrt(), step_BoxCox() Transformaciones
step_dummy() Factor → variables dummy
step_other() Agrupar categorías raras en “other”
step_unknown() NA en factor → categoría “unknown”
step_impute_mean(), step_impute_median(), step_impute_mode() Imputación simple
step_impute_knn(), step_impute_bag() Imputación con modelos
step_zv(), step_nzv() Eliminar variables con varianza (casi) cero
step_corr() Eliminar variables muy correlacionadas
step_interact() Crear interacciones
step_pca(), step_kpca() Reducción de dimensionalidad

El catálogo completo (~80 steps) cubre prácticamente cualquier preprocesado estándar.

El orden importa

Los steps se ejecutan en el orden declarado. Algunos requieren un orden específico:

# Orden correcto
receta_buena <- recipe(price ~ ., data = train) |>
  step_impute_median(all_numeric_predictors()) |>     # 1. Imputar primero
  step_log(price, base = 10) |>                       # 2. Transformar respuesta
  step_normalize(all_numeric_predictors()) |>         # 3. Normalizar (ya sin NA)
  step_dummy(all_nominal_predictors())                # 4. Dummies al final

¿Por qué este orden?

  • Imputación primero: si normalizas antes, los NA pueden propagarse o ser tratados como ceros, contaminando los estadísticos.
  • Log antes de normalizar: log transforma la distribución. Normalizar después de log tiene sentido. Al revés, no.
  • Dummies al final: después de las dummies hay más columnas, y aplicar all_numeric_predictors() después incluiría las dummies (raramente lo que quieres).

Si el orden es incorrecto, la receta no falla, produce algo distinto a lo que pretendes. Léelo siempre.

prep(), bake(), juice(): qué hace cada uno

Una receta declarada NO ejecuta nada. Para ejecutarla, hay tres verbos:

# 1. prep() — ejecuta los cálculos sobre TRAIN
receta_prep <- prep(receta, training = train)

# 2. juice() — devuelve los datos de TRAIN procesados
train_procesado <- juice(receta_prep)

# 3. bake() — aplica la receta procesada a NUEVOS datos
test_procesado <- bake(receta_prep, new_data = test)

Diferencias clave:

  • prep() aprende los parámetros del preprocesado (media, SD, vocabulario de factores, valores de imputación) usando solo train.
  • juice() te da el train procesado, es atajo para bake(receta_prep, new_data = NULL).
  • bake() aplica la receta entrenada a nuevos datos (test, validación o producción).

Esta arquitectura es la regla de oro. Los parámetros aprendidos en prep() se calculan una vez, sobre train, y se aplican idénticamente al test. Imposible filtrar información.

En la práctica, rara vez vas a llamar a prep(), juice() o bake() manualmente, workflow() (siguiente tutorial) los orquesta. Pero cuando depures problemas, conocerlos es esencial.

Recipe vs transformación en pipeline

Comparación práctica con/sin recipe:

# Sin recipe (peligroso): cálculo sobre todo el dataset
diamonds_norm <- diamonds |>
  mutate(carat = (carat - mean(carat)) / sd(carat))
split <- initial_split(diamonds_norm, prop = 0.8)
# ❌ FUGA: media y SD de carat se calcularon con info de test

# Con recipe (correcto): cálculo se posterga
split <- initial_split(diamonds, prop = 0.8)
receta <- recipe(price ~ ., data = training(split)) |>
  step_normalize(carat)
# ✅ La normalización ocurrirá dentro del fit, solo con train

Esto es lo que hace recipes distinto. No es una alternativa estilística, es una garantía de validez metodológica.

Trampas habituales

  • Imputar antes de dividir. mutate(across(everything(), ~ replace_na(.x, mean(.x)))) aplicado antes de initial_split() filtra información de test al train. La imputación debe estar dentro de la receta, no antes de la división.
  • Olvidar step_dummy() o step_unknown() para factores. Modelos como glmnet o redes neuronales no aceptan factores directamente, necesitan dummies. Si el test tiene niveles que no estaban en train, step_unknown() los manda a una categoría predecible en lugar de generar NA.
  • step_zv() o step_nzv() aplicados al outcome. Casi siempre quieres aplicar steps solo a predictores. Usa selectores: step_zv(all_predictors()), no step_zv() solo.
  • No revisar la receta antes de entrenarla. tidy(receta_prep) te enseña los parámetros aprendidos por step. Útil para verificar que step_normalize aprendió las medias esperadas, que step_dummy capturó todos los niveles, etc.

En la siguiente entrega

Has aprendido a preprocesar sin fugas. La siguiente pieza es especificar el modelo, qué algoritmo usar, qué engine (ranger, xgboost, glmnet, etc.). parsnip desacopla la especificación de la implementación, lo que te permite intercambiar engines sin reescribir el código. Es lo siguiente.