Cross-validation correcto

r
machine-learning
tidymodels
Cómo estimar generalización sin tocar el test set. fit_resamples(), collect_metrics, collect_predictions, CV repetida cuando hace falta y por qué CV no sustituye al test set.

¿Por qué cross-validation?

El problema central de ML: ¿cómo sé si mi modelo va a funcionar con datos nuevos, sin gastar mi test set?

Test set sirve una sola vez, la evaluación final. Si lo usas durante el desarrollo (probar 5 modelos, quedarte con el mejor según test), lo has contaminado: el “mejor en test” probablemente está sobreajustado a ese test específico.

Cross-validation soluciona esto. Entrenas y evalúas sobre el train set, particionado en folds. La métrica resultante es una estimación honesta de generalización sin tocar test.

tidymodels lo orquesta con fit_resamples().

fit_resamples(): la operación core

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

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

# 5-fold CV sobre el train
set.seed(456)
folds <- vfold_cv(train, v = 5, strata = price)

# Receta + modelo + workflow (ver tutoriales anteriores)
receta <- recipe(price ~ ., data = train) |>
  step_log(price, base = 10) |>
  step_normalize(all_numeric_predictors()) |>
  step_dummy(all_nominal_predictors())

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

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

# Cross-validation: entrena en cada fold y evalúa
res <- fit_resamples(
  wf,
  resamples = folds,
  metrics   = metric_set(rmse, rsq, mae)
)

fit_resamples() ejecuta:

  1. Para cada fold (5 veces):
    • Aplica la receta usando los datos del fold de entrenamiento.
    • Entrena el modelo.
    • Predice sobre el fold de evaluación.
    • Calcula las métricas.
  2. Devuelve un objeto con métricas por fold.

Esto es lo que necesitas para estimar generalización.

collect_metrics: resumen agregado

collect_metrics(res)
#> # A tibble: 3 × 6
#>   .metric .estimator   mean     n std_err .config
#>   <chr>   <chr>       <dbl> <int>   <dbl> <chr>
#> 1 mae     standard    0.073     5 0.00124 Preprocessor1_Model1
#> 2 rmse    standard    0.099     5 0.00211 Preprocessor1_Model1
#> 3 rsq     standard    0.967     5 0.00103 Preprocessor1_Model1

Por métrica, te da:

  • mean: media a través de los 5 folds.
  • std_err: error estándar entre folds. Si es alto, los folds dan resultados muy distintos, señal de inestabilidad.
  • n: número de folds.

La media es la estimación de generalización. El std_err te dice cuán fiable es esa estimación.

Para ver métricas por fold individual:

collect_metrics(res, summarize = FALSE)

collect_predictions: las predicciones crudas

Si quieres analizar errores, residuos, o calibrar:

preds <- collect_predictions(res)
preds
#> # A tibble: 43,154 × 5
#>    .pred  id      .row price .config
#>    <dbl>  <chr>  <int> <dbl> <chr>
#>  1 7.213  Fold1     1   326 Preprocessor1_Model1
#>  2 7.421  Fold1     5   327 Preprocessor1_Model1
#>  ...

Cada fila es una predicción out-of-fold (hecha cuando esa observación NO estaba en el train del fold).

Para visualizar predicho vs real:

library(ggplot2)

preds |>
  ggplot(aes(price, .pred)) +
  geom_point(alpha = 0.1) +
  geom_abline(slope = 1, intercept = 0, color = "red") +
  scale_x_log10() + scale_y_log10() +
  labs(title = "Predicho vs observado (out-of-fold)")

geom_abline(slope = 1, intercept = 0) es la línea de predicción perfecta. Si los puntos se desvían sistemáticamente de ella, el modelo tiene sesgo en alguna región.

CV repetida cuando hace falta

5-fold CV te da 5 estimaciones, pero la división en folds tiene su propia aleatoriedad. Si la varianza entre folds es alta, una sola corrida puede engañar.

CV repetida (vfold_cv con repeats > 1) ejecuta el k-fold varias veces con divisiones distintas:

folds_rep <- vfold_cv(train, v = 5, repeats = 3, strata = price)
# 5 folds × 3 repeats = 15 estimaciones

Cuándo usarla:

  • Datasets pequeños (≤ 1000 obs): la varianza entre divisiones es alta. CV repetida reduce ruido.
  • Comparación de modelos cercanos: si dos modelos se separan por menos del std_err, hace falta más resoluciones para distinguirlos.

Cuándo NO necesitas: datasets grandes (decenas de miles), la varianza entre folds ya es baja con una sola corrida.

Coste: v × repeats entrenamientos. Con 5×5 = 25 fits, modelos lentos se vuelven prohibitivos.

CV NO sustituye al test set

Punto importante que se confunde con frecuencia:

  • CV sobre train estima generalización durante el desarrollo, la usas para elegir modelos, comparar hiperparámetros, decidir features.
  • Test set evalúa el modelo final una sola vez, confirma que la elección hecha con CV sigue siendo válida sobre datos completamente nuevos.

Si tu CV dice AUC = 0.85 y el test set dice AUC = 0.83, es normal y esperable, leve degradación por la diferencia de muestras. Si la diferencia es grande (CV 0.85, test 0.65), hay un problema: probablemente data leakage o sobreajuste sutil.

Nunca repitas el ciclo “evaluar en test → ajustar modelo → volver a evaluar en test”. Eso es contaminar el test. Si después de evaluar en test quieres cambiar algo, técnicamente el resultado final ya no es válido y necesitas datos nuevos para evaluar de verdad.

Comparar dos modelos con CV

Patrón típico: probar dos modelos sobre los mismos folds y comparar honestamente:

# Modelo A: random forest
wf_rf <- workflow() |>
  add_recipe(receta) |>
  add_model(rand_forest(trees = 500) |> set_engine("ranger") |> set_mode("regression"))

# Modelo B: regresión lineal
wf_lm <- workflow() |>
  add_recipe(receta) |>
  add_model(linear_reg() |> set_engine("lm"))

res_rf <- fit_resamples(wf_rf, resamples = folds, metrics = metric_set(rmse))
res_lm <- fit_resamples(wf_lm, resamples = folds, metrics = metric_set(rmse))

collect_metrics(res_rf)
collect_metrics(res_lm)

Si la diferencia entre las medias es notablemente mayor que el std_err, el resultado es robusto. Si es del mismo orden, la diferencia podría ser ruido del muestreo.

Para comparar muchos modelos de una vez, workflowsets (tutorial 10) automatiza esto sin escribir cada fit_resamples a mano.

Trampas habituales

  • set.seed() ausente o solo una vez. Para reproducibilidad, pon set.seed() antes de vfold_cv() Y antes de modelos estocásticos (random forest, neural net). Sin seeds, cada ejecución da resultados ligeramente distintos.
  • Comparar modelos con folds distintos. Si llamas vfold_cv() dos veces sin la misma semilla, los folds son distintos y la comparación no es válida. Define los folds una vez y reúsalos en todas las comparaciones.
  • Confundir CV con bootstrap. Bootstrap muestrea con reemplazo del mismo tamaño que el original. CV particiona sin reemplazo. Para estimar generalización, CV es lo estándar. Bootstrap se usa más para estimar incertidumbre en métricas.
  • Mirar CV durante el desarrollo y test al final, pero ajustar el preprocesado en respuesta al test. Si haces “el test dio AUC 0.7, voy a añadir esta feature” y vuelves a evaluar, has contaminado el test. La regla es estricta.

En la siguiente entrega

Has aprendido a evaluar honestamente con CV. La siguiente pieza es el tuning de hiperparámetros: cómo encontrar la mejor configuración del modelo sin trampas. tune orquesta esto sobre los folds que ya tienes. Lo siguiente.