Caso completo: del CSV a un modelo defendible

r
machine-learning
tidymodels
Un proyecto de ML end-to-end con tidymodels. Predicción de impago en créditos con división, recipe, comparación de 3 modelos vía workflowsets, tuning del ganador, evaluación final en test e interpretabilidad.

El cierre de la ruta

Has completado 11 tutoriales sobre tidymodels. Esta entrega no introduce conceptos nuevos: junta todo en un proyecto real de principio a fin, con cada decisión justificada.

Usamos modeldata::credit_data, un dataset de 4 454 solicitudes de crédito con 14 variables, donde la pregunta es: “¿este solicitante va a impagar?”. Clasificación binaria, clases moderadamente desbalanceadas (~28 % de impagos), variables mezcladas (numéricas + categóricas + NA reales). Es un caso suficientemente complejo para mostrar las decisiones que importan.

El problema

“Construir un clasificador que prediga el impago de un crédito a partir de las características del solicitante, defendible frente a una auditoría y desplegable en producción.”

Paso 0: Carga y exploración

library(tidymodels)
library(workflowsets)
library(finetune)
library(future)

data(credit_data, package = "modeldata")
glimpse(credit_data)
#> Rows: 4,454
#> Columns: 14
#> $ Status    <fct> good, good, bad, good, good, ...
#> $ Seniority <int> 9, 17, 10, 0, 0, ...
#> $ Home      <fct> rent, rent, owner, rent, rent, ...
#> $ Time      <int> 60, 60, 36, 60, 36, ...
#> $ Age       <int> 30, 58, 46, 24, 26, ...
#> $ Marital   <fct> married, widow, married, single, single, ...
#> $ Records   <fct> no_rec, no_rec, yes_rec, no_rec, no_rec, ...
#> $ Job       <fct> freelance, fixed, freelance, fixed, fixed, ...
#> $ Expenses  <int> 73, 48, 90, 63, 46, ...
#> $ Income    <int> 129, 131, 200, 182, 107, ...
#> $ Assets    <int> 0, 0, 3000, 2500, 0, ...
#> $ Debt      <int> 0, 0, 0, 0, 0, ...
#> $ Amount    <int> 800, 1000, 2000, 900, 310, ...
#> $ Price     <int> 846, 1658, 2985, 1325, 910, ...

Inspección rápida:

library(skimr)
skim(credit_data)

Observaciones clave:

  • Status es la variable objetivo (good/bad). bad (impago) ≈ 28 %.
  • Hay NA en Income, Assets, Debt, necesitamos imputación.
  • Variables categóricas (Home, Marital, Records, Job), requieren dummies.

Paso 1: División con estratificación

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

set.seed(456)
folds <- vfold_cv(train, v = 5, strata = Status)

Justificación: 80/20 (n = 4 454 da margen suficiente). Estratificación por Status para mantener la proporción de impagos en train y test. 5-fold CV sobre train.

Paso 2: Receta

receta <- recipe(Status ~ ., data = train) |>
  step_impute_median(all_numeric_predictors()) |>
  step_unknown(all_nominal_predictors()) |>
  step_normalize(all_numeric_predictors()) |>
  step_dummy(all_nominal_predictors())

Decisiones:

  • Imputación con mediana para numéricas. Más robusta que media con NA.
  • step_unknown convierte NA en factor en categoría "unknown". Evita que el modelo falle por NA y captura información sobre “no respondió” como predictor.
  • Normalización para que algoritmos sensibles a escala (glmnet) no se desequilibren.
  • Dummies para factores. Necesario para todos los engines del workflow set.

Orden importa: imputar antes de normalizar (si normalizas primero, los NA dominan los estadísticos).

Paso 3: Tres modelos para comparar

spec_glmnet <- logistic_reg(penalty = tune(), mixture = tune()) |>
  set_engine("glmnet")

spec_rf <- rand_forest(mtry = tune(), min_n = tune(), trees = 500) |>
  set_engine("ranger", importance = "permutation") |>
  set_mode("classification")

spec_xgb <- boost_tree(
  trees = 500, learn_rate = tune(), tree_depth = tune(), min_n = tune()
) |>
  set_engine("xgboost") |>
  set_mode("classification")

Justificación de la triada:

  • glmnet (regresión logística regularizada), baseline lineal interpretable.
  • random forest: modelo no lineal robusto, poco overfitting, buen default.
  • XGBoost: gradient boosting, suele ser el más preciso con datos tabulares.

importance = "permutation" en ranger pide al engine que calcule importancia permutation-based en el fit, ahorrará trabajo en el paso de interpretabilidad.

Paso 4: Workflow set + tuning paralelo con racing

wfs <- workflow_set(
  preproc = list(base = receta),
  models  = list(glmnet = spec_glmnet, rf = spec_rf, xgb = spec_xgb)
)

plan(multisession, workers = 4)

set.seed(789)
resultados <- workflow_map(
  wfs,
  fn        = "tune_race_anova",
  resamples = folds,
  grid      = 20,
  metrics   = metric_set(roc_auc, pr_auc, accuracy, f_meas),
  control   = control_race(verbose_elim = TRUE, save_pred = TRUE)
)

Decisiones:

  • tune_race_anova descarta combinaciones malas pronto, speedup 3-5× sin pérdida de calidad.
  • metric_set(roc_auc, pr_auc, accuracy, f_meas): múltiples métricas para tener vista completa. Con clases desbalanceadas, PR AUC es más informativa que ROC AUC.
  • save_pred = TRUE guarda predicciones para análisis posterior (curvas ROC, calibración, residuos).

Paso 5: Ver el ranking

rank_results(resultados, rank_metric = "roc_auc", select_best = TRUE)

autoplot(resultados, metric = "roc_auc", select_best = TRUE) +
  ggplot2::theme_minimal()

Resultado esperable (varía con la semilla):

  • XGBoost en torno a ROC AUC 0.86-0.88.
  • Random forest en torno a 0.84-0.86.
  • glmnet en torno a 0.82-0.84.

Si la diferencia entre #1 y #2 es menor que el std_err (~0.008), están estadísticamente empatados. En ese caso, el modelo más simple gana (random forest sobre XGBoost, glmnet sobre random forest). La heurística one-standard-error rule.

Paso 6: Finalizar el ganador

Suponiendo que XGBoost ganó claramente:

mejor_id <- "base_xgb"

mejores_params <- resultados |>
  extract_workflow_set_result(mejor_id) |>
  select_best(metric = "roc_auc")

wf_final <- resultados |>
  extract_workflow(mejor_id) |>
  finalize_workflow(mejores_params)

Paso 7: Evaluación final en test (la única vez)

eval_final <- last_fit(wf_final, split,
                       metrics = metric_set(roc_auc, pr_auc, accuracy, f_meas))

collect_metrics(eval_final)
#> # A tibble: 4 × 4
#>   .metric  .estimator .estimate .config
#>   <chr>    <chr>          <dbl> <chr>
#> 1 accuracy binary         0.808 Preprocessor1_Model1
#> 2 f_meas   binary         0.624 Preprocessor1_Model1
#> 3 roc_auc  binary         0.867 Preprocessor1_Model1
#> 4 pr_auc   binary         0.732 Preprocessor1_Model1

Cosas que mirar:

  • ROC AUC en test (0.867) debería estar cerca de la AUC en CV (~0.87). Si está mucho más baja, hay sobreajuste sutil.
  • F1 = 0.62 te dice que aún hay margen, el balance precision/recall no es perfecto. Si el coste de falsos negativos es alto, podrías ajustar el umbral de clasificación.
# Predicciones detalladas
preds <- collect_predictions(eval_final)

# Curva ROC en test
preds |>
  roc_curve(truth = Status, .pred_bad) |>
  autoplot()

# Matriz de confusión
preds |>
  conf_mat(truth = Status, estimate = .pred_class)

Paso 8: Interpretabilidad: defender el modelo

library(DALEX)
library(vip)

# Modelo final entrenado en todo el train
wf_fit_final <- fit(wf_final, train)

# Variable importance con vip
extract_fit_parsnip(wf_fit_final) |>
  vip(num_features = 10, geom = "col") +
  ggplot2::theme_minimal()

Las top variables esperables: Records (antecedentes de impago), Seniority (años en el empleo), Income, Amount, Price. La importancia del impago previo no sorprende, pero confirma que el modelo aprende lo correcto.

Para análisis más profundo con DALEX:

explainer <- explain(
  model = extract_fit_engine(wf_fit_final),
  data  = train |> select(-Status),
  y     = as.integer(train$Status == "bad"),
  label = "XGBoost",
  type  = "classification"
)

# Importancia global con IC
imp <- model_parts(explainer)
plot(imp)

# PDP de la variable más importante
pdp <- model_profile(explainer, variables = "Income")
plot(pdp)

# Explicación de UN solicitante concreto
caso <- train[1, ] |> select(-Status)
shap <- predict_parts(explainer, new_observation = caso, type = "shap")
plot(shap)

Con esto, frente a una auditoría puedes:

  • Mostrar variable importance global (qué pesa más en general).
  • Mostrar PDP (cómo influye una variable concreta en promedio).
  • Explicar una decisión individual con SHAP (“este solicitante fue clasificado como riesgo alto porque…”).

Es la batería mínima para defender un modelo de ML en producción.

Paso 9: Serializar para deployment

# Workflow completo, listo para producción
saveRDS(wf_fit_final, "modelos/credit_xgb_v1.rds")

Para deployment con API REST, versionado y monitoreo:

library(vetiver)

v <- vetiver_model(wf_fit_final, "credit_classifier")
v

vetiver empaqueta el workflow, registra metadatos, expone como API con plumber y permite monitoreo de degradación en producción. Es el siguiente paso natural una vez tienes el modelo final.

Lo que has hecho

En este caso completo has aplicado los once tutoriales anteriores en orden:

  1. Identificar el problema como ML (predecir, no inferir).
  2. Mapeo del ecosistema tidymodels al flujo.
  3. División estratificada con rsample.
  4. Receta limpia con imputación, dummies y normalización.
  5. Tres especificaciones de modelo via parsnip.
  6. workflowsets para combinar y comparar.
  7. CV honesta con folds estratificados.
  8. Tuning con racing methods.
  9. Métricas múltiples apropiadas al desbalanceo.
  10. Selección del ganador con criterio (no solo máxima AUC).
  11. Interpretabilidad con vip y DALEX, defensa frente a auditoría.

Y has terminado con un workflow serializado, listo para vetiver o saveRDS directo.

¿Quieres ir más a fondo?

El libro Machine Learning con tidymodels (en preparación) desarrolla esta misma materia con:

  • Un caso de producción real (no académico) con dataset propio mucho más grande.
  • Deployment completo con vetiver, plumber y monitoreo en producción.
  • Modelos avanzados: stacking con stacks, calibración con probably, drift detection.
  • Capítulo sobre fairness y bias en ML para regulación (GDPR, AI Act).

Has terminado la Ruta 4

Felicidades. Si quieres seguir aprendiendo R, las rutas que naturalmente continúan son:

  • Shiny: análisis interactivo en R: del modelo estático a la app que stakeholders usan.
  • Reproducibilidad con Quarto: para que el análisis sea ejecutable en otra máquina mañana.

Ambas están en el listado de Rutas.