workflows: el contenedor que evita el caos
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 deparsnip.
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ó:
prep()aplica la receta usandotrain.juice()extrae el train procesado.fit()entrena el modelo sobre el train procesado.- 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:
bake()aplica la receta entrenada atest.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.importanceActualizar 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()oadd_model(). Un workflow incompleto falla alfit()con error opaco. Verifica conprint(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, necesitasadd_recipe(). Mezclar fórmula + recipe en el mismo workflow no funciona. saveRDS(modelo_ranger)en lugar desaveRDS(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 quewfcambie 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.