recipes: preprocesado sin fugas de datos
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 declarapricecomo respuesta y todo lo demás como predictores.data = trainproporciona 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 parabake(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 trainEsto 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 deinitial_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()ostep_unknown()para factores. Modelos comoglmneto 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 generarNA. step_zv()ostep_nzv()aplicados al outcome. Casi siempre quieres aplicar steps solo a predictores. Usa selectores:step_zv(all_predictors()), nostep_zv()solo.- No revisar la receta antes de entrenarla.
tidy(receta_prep)te enseña los parámetros aprendidos por step. Útil para verificar questep_normalizeaprendió las medias esperadas, questep_dummycapturó 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.