workflows: el contenedor que evita el caos

r
machine-learning
tidymodels
El objeto que combina recipe + modelo en una unidad serializable. fit() y predict() unificados, actualización de partes con update_recipe / update_model, y serialización para producción.

El problema sin workflow

Sin workflows, hacer un pipeline completo requiere coordinar manualmente:

# Sin workflow — frágil
receta_prep <- prep(receta, training = train)
train_proc  <- juice(receta_prep)
modelo_fit  <- fit(modelo_spec, outcome ~ ., data = train_proc)

# Predicción: hay que recordar aplicar la misma receta al test
test_proc   <- bake(receta_prep, new_data = test)
predict(modelo_fit, new_data = test_proc)

Cuatro pasos manualmente coordinados. Cualquier desliz (olvidar bake, mezclar versiones de la receta, perder receta_prep) y la predicción es incorrecta.

workflows convierte ese mosaico en un único objeto:

library(tidymodels)

wf <- workflow() |>
  add_recipe(receta) |>
  add_model(modelo_spec)

wf_fit <- fit(wf, data = train)
predict(wf_fit, new_data = test)

Dos llamadas. El workflow recuerda la receta, los parámetros aprendidos del preprocesado y el modelo entrenado. La predicción aplica todo automáticamente.

workflow() y los dos add_*

wf <- workflow() |>
  add_recipe(receta) |>
  add_model(modelo_spec)

Dos métodos para añadir componentes:

  • add_recipe(): el preprocesado.
  • add_model(): la spec de parsnip.

Cada workflow tiene una sola receta y un solo modelo. Si quieres comparar varios modelos sobre la misma receta, usas workflowsets (tutorial 10).

Alternativa sin receta, para preprocesado mínimo se puede usar una fórmula:

wf_simple <- workflow() |>
  add_formula(outcome ~ x1 + x2 + x3) |>
  add_model(modelo_spec)

add_formula() se usa cuando NO hay receta, parsnip aplica un preprocesado mínimo (dummies para factores, principalmente). Para cualquier cosa más compleja, usa add_recipe().

fit() en un workflow: una sola llamada

wf_fit <- fit(wf, data = train)

Bajo el capó:

  1. prep() aplica la receta usando train.
  2. juice() extrae el train procesado.
  3. fit() entrena el modelo sobre el train procesado.
  4. Empaqueta todo en un workflow ajustado.

El objeto resultante contiene el modelo entrenado Y la receta preparada. Es lo que se serializa para producción.

predict() que aplica el preprocesado solo

preds <- predict(wf_fit, new_data = test)

Lo que pasa internamente:

  1. bake() aplica la receta entrenada a test.
  2. predict() corre el modelo sobre los datos procesados.

Tú no tocas el preprocesado. Esto es lo que evita errores de coordinación.

Para predicciones con clase + probabilidades en clasificación:

predict(wf_fit, new_data = test, type = "prob")
predict(wf_fit, new_data = test, type = "class")

Por defecto en clasificación devuelve "class". Casi siempre quieres ambas para calcular AUC.

Inspeccionar el workflow

Cuando depures problemas, hay verbos para extraer cada parte:

extract_recipe(wf_fit)         # la receta preparada
extract_fit_parsnip(wf_fit)    # el modelo ajustado (objeto parsnip)
extract_fit_engine(wf_fit)     # el modelo del engine (ranger, glmnet, etc.)
extract_preprocessor(wf_fit)   # la receta original (sin prep)
extract_spec_parsnip(wf_fit)   # la spec de parsnip

Útil cuando algo va mal y quieres mirar el engine subyacente:

modelo_ranger <- extract_fit_engine(wf_fit)
modelo_ranger$variable.importance

Actualizar partes: update_*

A veces quieres mantener el resto del workflow pero cambiar una pieza:

# Cambiar la receta sin tocar el modelo
wf_v2 <- wf |> update_recipe(receta_nueva)

# Cambiar el modelo sin tocar la receta
wf_v3 <- wf |> update_model(modelo_spec_nuevo)

Cuando exploras alternativas (probar xgboost en lugar de ranger con el mismo preprocesado), update_model() evita reescribir todo.

Serializar para producción

Un workflow entrenado se guarda y carga como cualquier objeto R:

# Guardar
saveRDS(wf_fit, "modelo_v1.rds")

# Cargar (en otro script, otro servidor, otro momento)
modelo <- readRDS("modelo_v1.rds")
preds  <- predict(modelo, new_data = datos_nuevos)

El RDS contiene todo: receta preparada, modelo entrenado, y los parámetros aprendidos durante el preprocesado. En producción, esto es lo que vas a desplegar.

Para deploys más serios, el ecosistema vetiver (vetiver_model, vetiver_pin_write) gestiona versionado, API REST y monitoreo, pero el corazón es siempre el workflow serializado.

Trampa de serialización: si el engine tiene dependencias compiladas (xgboost, lightgbm), el entorno donde cargas debe tenerlas instaladas en versión compatible. vetiver resuelve esto declarando explícitamente las dependencias.

Patrón completo end-to-end

Resumiendo lo aprendido hasta aquí:

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

# 1. División
set.seed(123)
split <- initial_split(diamonds, prop = 0.8, strata = price)
train <- training(split)
test  <- testing(split)

# 2. Receta
receta <- recipe(price ~ ., data = train) |>
  step_log(price, base = 10) |>
  step_normalize(all_numeric_predictors()) |>
  step_dummy(all_nominal_predictors())

# 3. Modelo
modelo_spec <- rand_forest(trees = 500) |>
  set_engine("ranger") |>
  set_mode("regression")

# 4. Workflow
wf <- workflow() |>
  add_recipe(receta) |>
  add_model(modelo_spec)

# 5. Entrenar
wf_fit <- fit(wf, data = train)

# 6. Evaluar en test
preds <- predict(wf_fit, new_data = test) |>
  bind_cols(test |> mutate(price_log = log10(price)))

rmse(preds, truth = price_log, estimate = .pred)

Seis pasos, cada uno con responsabilidad clara. Esto es lo que hace tidymodels distinto de caret, la separación es real y mantenible.

Trampas habituales

  • Olvidar add_recipe() o add_model(). Un workflow incompleto falla al fit() con error opaco. Verifica con print(wf) antes de entrenar, te dice qué le falta.
  • Usar add_formula() cuando la receta tiene steps no triviales. Si el preprocesado va más allá de dummies básicas, necesitas add_recipe(). Mezclar fórmula + recipe en el mismo workflow no funciona.
  • saveRDS(modelo_ranger) en lugar de saveRDS(wf_fit). Si solo guardas el modelo del engine, pierdes la receta. Al cargar en producción, no sabrás cómo procesar los datos nuevos. Siempre guarda el workflow completo.
  • Modificar el workflow en sitio. wf |> update_model(...) devuelve un workflow nuevo. No muta el original. Si esperas que wf cambie sin asignarlo, no cambia.

En la siguiente entrega

Ya tienes el pipeline en un objeto coherente. Falta evaluar cuán bien generaliza antes de tocar test. Eso es lo que hace cross-validation correctamente, y fit_resamples() es la operación core. Lo siguiente.