IA para Científicos Sociales

Sesión 2.3: Laboratorio - Clasificación avanzada

Danilo Freire

Departament of Data and Decision Sciences
Emory University

Laboratorio 3: Clasificación avanzada

Objetivos del laboratorio

Lo que vamos a hacer:

  1. Predecir participación electoral
  2. Baseline con regresión logística
  3. Ajustar un Random Forest con CV
  4. Interpretar con VIP y PDP
  5. Comparar modelos en el conjunto de test

Lo que vamos a aprender:

  • Random Forest para clasificación
  • Tuning de hiperparámetros con grilla
  • Interpretación de modelos complejos
  • Cuándo vale la pena la complejidad extra


Trabajen en sus computadoras y pregunten si tienen dudas.

Parte 1: Preparación y baseline

Cargar los paquetes necesarios

# Instalar si es necesario
paquetes <- c("tidyverse", "tidymodels", "ranger", "vip", "pdp", "xgboost")

# require(): intenta cargar el paquete y devuelve TRUE/FALSE
# Si no está instalado (FALSE), lo instala y carga
for (pkg in paquetes) {
  if (!require(pkg, character.only = TRUE)) {
    install.packages(pkg, dependencies = TRUE)
    library(pkg, character.only = TRUE)
  }
}

# Cargar tidymodels (carga varios paquetes a la vez:
# rsample, parsnip, recipes, workflows, tune, yardstick, etc.)
library(tidymodels)
library(ranger)      # Motor rápido para Random Forest
library(vip)         # Variable Importance Plots
library(pdp)         # Partial Dependence Plots

# Configurar semilla para reproducibilidad
set.seed(2026)

Cargar y explorar los datos

# Cargar el dataset (generado con datos/crear-datos.R)
datos <- read_csv("datos/latinobarometro_sim.csv", show_col_types = FALSE)

# Convertir variables categóricas a factores
# El primer nivel de voto es la clase positiva para yardstick
datos <- datos |>
  mutate(
    pais = factor(pais),
    zona = factor(zona),
    genero = factor(genero),
    uso_internet = factor(uso_internet, levels = c("nunca", "semanal", "diario")),
    voto = factor(voto, levels = c("si", "no"))
  )

# Estructura y distribución del voto
glimpse(datos)
Rows: 500
Columns: 14
$ pais                    <fct> Argentina, Costa Rica, Panamá, Perú, Nicaragua…
$ edad                    <dbl> 65, 19, 82, 54, 32, 21, 57, 21, 57, 80, 38, 69…
$ educacion_anios         <dbl> 15, 18, 11, 9, 14, 9, 7, 17, 3, 12, 12, 14, 9,…
$ ingreso_hogar           <dbl> 7, 10, 3, 8, 5, 5, 5, 9, 3, 7, 6, 6, 5, 8, 9, …
$ zona                    <fct> urbana, rural, urbana, urbana, urbana, urbana,…
$ genero                  <fct> hombre, hombre, mujer, mujer, hombre, hombre, …
$ confianza_gobierno      <dbl> 2, 3, 2, 4, 5, 5, 2, 4, 3, 1, 1, 2, 3, 3, 3, 3…
$ confianza_justicia      <dbl> 3, 3, 3, 2, 4, 2, 2, 4, 2, 3, 3, 2, 4, 3, 4, 3…
$ satisfaccion_democracia <dbl> 2, 1, 2, 2, 4, 3, 3, 4, 3, 3, 2, 4, 2, 2, 2, 3…
$ percepcion_economia     <dbl> 3, 5, 5, 5, 2, 5, 4, 2, 3, 3, 4, 4, 4, 1, 2, 4…
$ uso_internet            <fct> semanal, diario, diario, diario, nunca, semana…
$ interes_politica        <dbl> 3, 3, 3, 3, 3, 4, 4, 1, 1, 1, 1, 1, 2, 1, 3, 1…
$ satisfaccion_vida       <dbl> 8.6, 8.3, 5.3, 7.8, 6.3, 8.7, 5.9, 6.4, 4.5, 6…
$ voto                    <fct> no, si, si, no, si, si, no, si, no, si, si, no…
datos |> count(voto) |> mutate(proporcion = n / sum(n))
# A tibble: 2 × 3
  voto      n proporcion
  <fct> <int>      <dbl>
1 si      269      0.538
2 no      231      0.462

Dividir los datos

# initial_split(): divide los datos aleatoriamente en train y test
# prop = 0.75: 75% para entrenamiento, 25% para test
# strata = voto: estratificar para mantener la proporción de clases
division <- initial_split(datos, prop = 0.75, strata = voto)

datos_train <- training(division)
datos_test <- testing(division)

# Verificar proporciones
cat("Proporción en train:\n")
Proporción en train:
prop.table(table(datos_train$voto))

       si        no 
0.5320856 0.4679144 
cat("\nProporción en test:\n")

Proporción en test:
prop.table(table(datos_test$voto))

      si       no 
0.531746 0.468254 

Preprocesamiento con recipes

# recipe(): define el preprocesamiento como una "receta de cocina"
# voto ~ ...: la fórmula indica variable objetivo ~ predictores
receta <- recipe(voto ~ edad + educacion_anios + ingreso_hogar + zona +
                 genero + confianza_gobierno + confianza_justicia +
                 satisfaccion_democracia + percepcion_economia +
                 uso_internet + interes_politica,
                 data = datos_train) |>
  # step_dummy(): convierte categóricas a variables indicadoras (0/1)
  step_dummy(all_nominal_predictors()) |>
  # step_normalize(): centra (media = 0) y escala (sd = 1) las numéricas
  step_normalize(all_numeric_predictors()) |>
  # step_zv(): elimina variables con varianza cero (constantes)
  step_zv(all_predictors())

# prep(): estima los parámetros de la receta (ej: medias para normalizar)
# juice(): aplica la receta y devuelve los datos transformados
receta |> prep() |> juice() |> glimpse()
Rows: 374
Columns: 13
$ edad                    <dbl> 0.7423100, -1.7060607, 0.1568301, 0.3165064, 0…
$ educacion_anios         <dbl> 0.978706699, 1.715198260, -0.494276422, -0.985…
$ ingreso_hogar           <dbl> 0.7595363, 2.0828278, 1.2006335, -0.1226580, -…
$ confianza_gobierno      <dbl> -0.3660562, 0.7561161, 1.8782884, -0.3660562, …
$ confianza_justicia      <dbl> 0.4637013, 0.4637013, -0.5445795, -0.5445795, …
$ satisfaccion_democracia <dbl> -0.5706998, -1.6652728, -0.5706998, 0.5238732,…
$ percepcion_economia     <dbl> -0.05190591, 1.63616459, 1.63616459, 0.7921293…
$ interes_politica        <dbl> 0.5042183, 0.5042183, 0.5042183, 1.5403591, -1…
$ voto                    <fct> no, no, no, no, no, no, no, no, no, no, no, no…
$ zona_urbana             <dbl> 0.5374762, -1.8555727, 0.5374762, 0.5374762, 0…
$ genero_mujer            <dbl> -0.9775241, -0.9775241, 1.0202574, 1.0202574, …
$ uso_internet_semanal    <dbl> 1.6091029, -0.6198026, -0.6198026, 1.6091029, …
$ uso_internet_diario     <dbl> -1.1870659, 0.8401608, 0.8401608, -1.1870659, …

Modelo baseline: Regresión logística

# logistic_reg(): modelo de regresión logística
# set_engine("glm"): usar el motor glm de R base
# set_mode("classification"): tarea de clasificación (no regresión)
modelo_logit <- logistic_reg() |>
  set_engine("glm") |>
  set_mode("classification")

# workflow(): combina preprocesamiento (receta) + modelo en un solo objeto
wf_logit <- workflow() |>
  add_recipe(receta) |>    # agregar la receta de preprocesamiento
  add_model(modelo_logit)  # agregar el modelo

# fit(): ajustar el workflow completo a los datos de entrenamiento
ajuste_logit <- fit(wf_logit, data = datos_train)

# predict(): genera predicciones de clase ("si"/"no")
# type = "prob": genera probabilidades para cada clase (.pred_no, .pred_si)
# bind_cols(): une las columnas de predicciones con los datos reales
pred_logit <- predict(ajuste_logit, datos_test) |>
  bind_cols(predict(ajuste_logit, datos_test, type = "prob")) |>
  bind_cols(datos_test |> select(voto))

# metrics(): calcula múltiples métricas de evaluación
# truth: variable real, estimate: predicción de clase, .pred_si: probabilidades
metricas_logit <- pred_logit |>
  metrics(truth = voto, estimate = .pred_class, .pred_si)

metricas_logit
# A tibble: 4 × 3
  .metric     .estimator .estimate
  <chr>       <chr>          <dbl>
1 accuracy    binary         0.611
2 kap         binary         0.214
3 mn_log_loss binary         0.659
4 roc_auc     binary         0.643

Matriz de confusión del baseline

# conf_mat(): construye la tabla de predicho vs. real
# autoplot(type = "heatmap"): visualización como mapa de calor
conf_mat(pred_logit, truth = voto, estimate = .pred_class) |>
  autoplot(type = "heatmap") +
  scale_fill_gradient(low = "white", high = "#2d4563") +
  labs(title = "Matriz de confusión - Regresión logística")

Ejercicio 1: Threshold óptimo

Instrucciones:

  1. Por defecto, clasificamos como “sí” si P(sí) > 0.5
  2. Pero este threshold puede no ser óptimo
  3. Usando pred_logit, creen predicciones con thresholds de 0.3, 0.5 y 0.7
  4. Calculen el F1-score con f_meas() para cada threshold
  5. ¿Cuál threshold maximiza el F1-score?

Pista: usen ifelse(.pred_si > t, "si", "no") para reclasificar y un for loop para probar varios valores.

Tómense 5 minutos para experimentar.

Apéndice 1: Solución

Parte 2: Random Forest con tuning

Definir Random Forest con hiperparámetros a ajustar

# rand_forest(): modelo de Random Forest
# tune(): marcador especial que indica "optimizar este valor automáticamente"
modelo_rf_tune <- rand_forest(
  mtry = tune(),       # Número de variables a considerar en cada split
  trees = 500,         # Número de árboles (fijo, no se optimiza)
  min_n = tune()       # Mínimo de observaciones en nodo terminal
) |>
  # importance = "impurity": calcular importancia con reducción de impureza (Gini)
  set_engine("ranger", importance = "impurity") |>
  set_mode("classification")

# workflow(): combina receta + modelo
wf_rf_tune <- workflow() |>
  add_recipe(receta) |>
  add_model(modelo_rf_tune)

# extract_parameter_set_dials(): muestra los hiperparámetros marcados con tune()
modelo_rf_tune |> extract_parameter_set_dials()

Crear la grilla de búsqueda

# grid_regular(): crea una grilla con valores uniformemente espaciados
# Cada parámetro tiene un rango definido; levels indica cuántos valores probar
grilla_rf <- grid_regular(
  mtry(range = c(2, 8)),    # De 2 a 8 variables por split
  min_n(range = c(5, 30)),  # De 5 a 30 obs mínimas por nodo
  levels = c(4, 4)          # 4 valores de cada uno = 4 x 4 = 16 combinaciones
)

# Ver la grilla
grilla_rf
# A tibble: 16 × 2
    mtry min_n
   <int> <int>
 1     2     5
 2     4     5
 3     6     5
 4     8     5
 5     2    13
 6     4    13
 7     6    13
 8     8    13
 9     2    21
10     4    21
11     6    21
12     8    21
13     2    30
14     4    30
15     6    30
16     8    30
# Alternativa: grilla aleatoria (más eficiente para muchos hiperparámetros)
# grilla_rf <- grid_random(
#   mtry(range = c(2, 8)),
#   min_n(range = c(5, 30)),
#   size = 20  # 20 combinaciones aleatorias
# )

Configurar validación cruzada

# vfold_cv(): divide los datos en v grupos (folds) para validación cruzada
# strata = voto: estratificar para que cada fold mantenga la proporción de clases
folds <- vfold_cv(datos_train, v = 5, strata = voto)

# Ver los folds
folds
#  5-fold cross-validation using stratification 
# A tibble: 5 × 2
  splits           id   
  <list>           <chr>
1 <split [299/75]> Fold1
2 <split [299/75]> Fold2
3 <split [299/75]> Fold3
4 <split [299/75]> Fold4
5 <split [300/74]> Fold5
# Cada fold tiene ~300 obs en training y ~75 en assessment


Nota

Usamos 5 folds para equilibrar entre precisión de la estimación y tiempo de cómputo. Con más folds (10) las estimaciones son más estables pero toma más tiempo.

Ejecutar el tuning

# tune_grid(): evalúa cada combinación de hiperparámetros con CV
# Para cada combinación de la grilla, entrena el modelo en cada fold
# y calcula las métricas especificadas
resultados_tune <- tune_grid(
  wf_rf_tune,               # el workflow con tune() pendientes
  resamples = folds,         # los folds de validación cruzada
  grid = grilla_rf,          # la grilla de combinaciones a probar
  # metric_set(): define qué métricas calcular en cada evaluación
  metrics = metric_set(accuracy, roc_auc, f_meas),
  control = control_grid(verbose = FALSE)  # no imprimir progreso
)

# collect_metrics(): extrae los resultados promediados de todos los folds
resultados_tune |>
  collect_metrics() |>
  filter(.metric == "roc_auc") |>
  arrange(desc(mean))
# A tibble: 16 × 8
    mtry min_n .metric .estimator  mean     n std_err .config         
   <int> <int> <chr>   <chr>      <dbl> <int>   <dbl> <chr>           
 1     2    13 roc_auc binary     0.630     5 0.0134  pre0_mod02_post0
 2     2    30 roc_auc binary     0.624     5 0.0141  pre0_mod04_post0
 3     6    30 roc_auc binary     0.620     5 0.00910 pre0_mod12_post0
 4     2    21 roc_auc binary     0.619     5 0.0169  pre0_mod03_post0
 5     4    13 roc_auc binary     0.618     5 0.0144  pre0_mod06_post0
 6     4    30 roc_auc binary     0.617     5 0.0116  pre0_mod08_post0
 7     2     5 roc_auc binary     0.614     5 0.0199  pre0_mod01_post0
 8     4    21 roc_auc binary     0.614     5 0.00988 pre0_mod07_post0
 9     8    30 roc_auc binary     0.610     5 0.0122  pre0_mod16_post0
10     6    21 roc_auc binary     0.610     5 0.00939 pre0_mod11_post0
11     8    21 roc_auc binary     0.607     5 0.00978 pre0_mod15_post0
12     8    13 roc_auc binary     0.606     5 0.0144  pre0_mod14_post0
13     6    13 roc_auc binary     0.605     5 0.0127  pre0_mod10_post0
14     4     5 roc_auc binary     0.599     5 0.0130  pre0_mod05_post0
15     6     5 roc_auc binary     0.597     5 0.0150  pre0_mod09_post0
16     8     5 roc_auc binary     0.585     5 0.0152  pre0_mod13_post0

Visualizar los resultados del tuning

# autoplot(): método genérico que sabe graficar objetos de tidymodels
autoplot(resultados_tune) +
  theme_minimal() +
  labs(title = "Resultados del tuning de Random Forest")

Seleccionar y ajustar el modelo final

# select_best(): elige la combinación con el mejor valor de la métrica
mejor_rf <- select_best(resultados_tune, metric = "roc_auc")
mejor_rf
# A tibble: 1 × 3
   mtry min_n .config         
  <int> <int> <chr>           
1     2    13 pre0_mod02_post0
# finalize_workflow(): reemplaza los tune() por los valores óptimos
# Luego ajustamos con todos los datos de entrenamiento
wf_rf_final <- finalize_workflow(wf_rf_tune, mejor_rf)
ajuste_rf <- fit(wf_rf_final, data = datos_train)

# Predicciones y métricas en test
pred_rf <- predict(ajuste_rf, datos_test) |>
  bind_cols(predict(ajuste_rf, datos_test, type = "prob")) |>
  bind_cols(datos_test |> select(voto))

metricas_rf <- pred_rf |>
  metrics(truth = voto, estimate = .pred_class, .pred_si)

cat("Regresión logística:\n"); print(metricas_logit)
Regresión logística:
# A tibble: 4 × 3
  .metric     .estimator .estimate
  <chr>       <chr>          <dbl>
1 accuracy    binary         0.611
2 kap         binary         0.214
3 mn_log_loss binary         0.659
4 roc_auc     binary         0.643
cat("\nRandom Forest:\n");     print(metricas_rf)

Random Forest:
# A tibble: 4 × 3
  .metric     .estimator .estimate
  <chr>       <chr>          <dbl>
1 accuracy    binary         0.619
2 kap         binary         0.224
3 mn_log_loss binary         0.676
4 roc_auc     binary         0.629

Curva ROC comparativa

# roc_curve(): calcula sensibilidad y especificidad para cada umbral
# truth: la variable real, .pred_si: probabilidades predichas
roc_logit <- pred_logit |>
  roc_curve(truth = voto, .pred_si) |>
  mutate(modelo = "Regresión logística")

roc_rf <- pred_rf |>
  roc_curve(truth = voto, .pred_si) |>
  mutate(modelo = "Random Forest")

# Combinar y graficar
bind_rows(roc_logit, roc_rf) |>
  ggplot(aes(x = 1 - specificity, y = sensitivity, color = modelo)) +
  geom_path(linewidth = 1.2) +
  geom_abline(linetype = "dashed", color = "gray50") +
  coord_equal() +
  labs(title = "Comparación de curvas ROC",
       x = "1 - Especificidad (Tasa de falsos positivos)",
       y = "Sensibilidad (Tasa de verdaderos positivos)",
       color = "Modelo") +
  theme_minimal()

Parte 3: Interpretación

Importancia de variables (Gini)

# extract_fit_parsnip(): extrae el modelo ajustado del workflow
# (devuelve un objeto parsnip, no el objeto nativo del motor)
modelo_extraido <- extract_fit_parsnip(ajuste_rf)

# vip(): gráfico de importancia de variables
# num_features: cuántas variables mostrar (las más importantes)
vip(modelo_extraido, num_features = 15) +
  labs(title = "Importancia de variables (Gini)",
       subtitle = "Random Forest para predicción de voto") +
  theme_minimal()

Interpretación de la importancia

¿Qué nos dice el gráfico?

  • Las variables demográficas dominan el ranking:

    • Edad es el predictor más fuerte
    • Educación y luego ingreso del hogar
  • Interés en la política es la actitud más predictiva, seguida de confianza en la justicia

  • Confianza en la justicia supera a confianza en el gobierno

  • Género, zona y uso de internet tienen baja importancia predictiva

Advertencia

Cuidado con la interpretación

La importancia de Gini mide predictibilidad, no efecto causal.

Que la edad sea importante no significa que “envejecer causa votar más”, solo que la edad ayuda a predecir quién votará.

Partial Dependence Plots

# extract_fit_engine(): extrae el objeto nativo del motor (ranger)
modelo_ranger <- extract_fit_engine(ajuste_rf)

# bake(): aplica la receta ya estimada a los datos de entrenamiento
datos_prep <- bake(prep(receta), new_data = datos_train)

# partial() calcula el efecto marginal sobre P(voto = "si")
# which.class = 1: primera clase del factor, es decir, "si"
pdp_edad <- partial(modelo_ranger, pred.var = "edad",
                    train = datos_prep, prob = TRUE, which.class = 1)

pdp_interes <- partial(modelo_ranger, pred.var = "interes_politica",
                       train = datos_prep, prob = TRUE, which.class = 1)

# Graficar lado a lado con patchwork (o cowplot)
library(patchwork)
p1 <- autoplot(pdp_edad) +
  labs(title = "PDP: Edad", x = "Edad (normalizada)",
       y = "P(voto = sí)") + theme_minimal()

p2 <- autoplot(pdp_interes) +
  labs(title = "PDP: Interés en política", x = "Interés (normalizado)",
       y = "P(voto = sí)") + theme_minimal()

p1 + p2

Interpretación de los PDPs

Edad:

  • La probabilidad de votar aumenta con la edad
  • El efecto es más fuerte entre los más viejos

Interés en política:

  • Relación monotónica positiva
  • Mayor interés → mayor probabilidad de votar
  • El efecto es aproximadamente lineal

Tip

PDPs vs. coeficientes

Los PDPs muestran relaciones no lineales que la regresión logística no captura.

Si el PDP es aproximadamente lineal, la regresión logística podría ser suficiente.

Si el PDP tiene curvas o mesetas, Random Forest captura patrones que otros modelos pierden.

Ejercicio 2: Análisis por país

Instrucciones:

  1. Filtrar los datos para Uruguay solamente
  2. Entrenar el mismo modelo de Random Forest (sin tuning, con hiperparámetros fijos)
  3. ¿Cambia la importancia de las variables?
  4. ¿La edad sigue siendo el predictor más importante?
# Tu código aquí
datos_uruguay <- datos |>
  filter(pais == "Uruguay")

# Crear receta y workflow, ajustar el modelo...
# Comparar el VIP con el modelo general

Pista: con pocos datos, usen hiperparámetros fijos en vez de tuning.

Apéndice 2: Solución

Parte 4: Discusión y cierre

¿Cuál modelo elegir?

Si el objetivo es predicción pura:

  • Elegir el modelo con mejor AUC
  • Random Forest suele superar a la regresión logística
  • La interpretabilidad es secundaria

Si el objetivo es entender los factores:

  • Regresión logística da coeficientes interpretables
  • Random Forest con VIP + PDP es un compromiso

Consideraciones prácticas:

  • Tiempo: RF es más lento que la regresión logística
  • Explicabilidad: ¿Podemos justificar las predicciones?
  • Mejora marginal: ¿Vale la pena 2% más de AUC?


En ciencias sociales, la interpretabilidad suele ser tan importante como el rendimiento.

Apéndice 4: XGBoost opcional

Resumen del laboratorio

Lo que practicamos:

  • Flujo completo de clasificación
  • Tuning de hiperparámetros con CV
  • Interpretación con VIP y PDP
  • Comparación de modelos en test

Conceptos clave:

  • tune() marca hiperparámetros a ajustar
  • grid_regular() crea combinaciones
  • tune_grid() evalúa con CV
  • select_best() elige la mejor combinación
  • finalize_workflow() aplica los valores


En el próximo laboratorio aplicaremos estos conceptos a regresión.

Continuar con el laboratorio de regresión

Apéndice: Soluciones

Apéndice 1: Threshold óptimo

# Probar thresholds de 0.3 a 0.7 y guardar los resultados
thresholds <- seq(0.3, 0.7, by = 0.05)
resultados <- data.frame(threshold = numeric(), f1 = numeric())

for (t in thresholds) {
  pred_nuevo <- pred_logit |>
    mutate(.pred_class_nuevo = factor(
      ifelse(.pred_si > t, "si", "no"),
      levels = c("si", "no")))

  f1 <- f_meas(pred_nuevo, truth = voto,
               estimate = .pred_class_nuevo)
  resultados <- rbind(resultados,
                      data.frame(threshold = t, f1 = f1$.estimate))
}

# Ver todos los resultados, ordenados de mejor a peor
resultados |> arrange(desc(f1))
  threshold        f1
1      0.30 0.6878307
2      0.45 0.6754967
3      0.40 0.6706587
4      0.35 0.6666667
5      0.50 0.6524823
6      0.55 0.6065574
7      0.60 0.4716981
8      0.65 0.4583333
9      0.70 0.3720930
# Visualizar
ggplot(resultados, aes(x = threshold, y = f1)) +
  geom_line(linewidth = 1.2, color = "#2d4563") +
  geom_point(size = 3, color = "#2d4563") +
  labs(title = "F1-score según el threshold de clasificación",
       x = "Threshold", y = "F1-score") +
  theme_minimal()

  • Un threshold más bajo (p. ej., 0.3) predice “sí” con más facilidad: aumenta el recall pero reduce la precisión
  • Un threshold más alto (p. ej., 0.7) es más conservador: menos falsos positivos, pero pierde más votantes reales
  • El threshold óptimo depende del costo relativo de cada tipo de error

Volver al ejercicio

Apéndice 2: Análisis por país

# Filtrar datos de Uruguay
datos_uruguay <- datos |> filter(pais == "Uruguay")
cat("Observaciones en Uruguay:", nrow(datos_uruguay), "\n")
Observaciones en Uruguay: 31 
# Con pocos datos (~28 obs), no dividimos en train/test
# Entrenamos con todo el subconjunto solo para comparar VIP
receta_uy <- recipe(voto ~ edad + educacion_anios + ingreso_hogar + zona +
                    genero + confianza_gobierno + confianza_justicia +
                    satisfaccion_democracia + percepcion_economia +
                    uso_internet + interes_politica,
                    data = datos_uruguay) |>
  step_dummy(all_nominal_predictors()) |>
  step_normalize(all_numeric_predictors()) |>
  step_zv(all_predictors())

modelo_rf_uy <- rand_forest(trees = 500, mtry = 4, min_n = 3) |>
  set_engine("ranger", importance = "impurity") |>
  set_mode("classification")

ajuste_rf_uy <- workflow() |>
  add_recipe(receta_uy) |>
  add_model(modelo_rf_uy) |>
  fit(data = datos_uruguay)

# Comparar importancia de variables
vip(extract_fit_parsnip(ajuste_rf_uy), num_features = 10) +
  labs(title = "Importancia de variables - solo Uruguay",
       subtitle = paste0("n = ", nrow(datos_uruguay), " observaciones")) +
  theme_minimal()

  • Con tan pocos datos (~28 obs), los resultados son inestables: cambian con la semilla
  • El ranking de importancia puede diferir del modelo general
  • Para conclusiones confiables a nivel país, necesitaríamos muestras más grandes

Volver al ejercicio

Apéndice 3: Árbol de decisión

# Modelo de árbol con costo de complejidad a ajustar
modelo_arbol <- decision_tree(
  cost_complexity = tune(),
  tree_depth = 10,
  min_n = 10
) |>
  set_engine("rpart") |>
  set_mode("classification")

# Workflow con la misma receta
wf_arbol <- workflow() |>
  add_recipe(receta) |>
  add_model(modelo_arbol)

# Grilla de búsqueda para cost_complexity
# range en escala log10: 10^-4 = 0.0001 a 10^-1 = 0.1
grilla_arbol <- grid_regular(
  cost_complexity(range = c(-4, -1)),
  levels = 10
)

# Tuning con validación cruzada (reutilizamos folds)
resultados_arbol <- tune_grid(
  wf_arbol,
  resamples = folds,
  grid = grilla_arbol,
  metrics = metric_set(roc_auc),
  control = control_grid(verbose = FALSE)
)

# Mejor modelo
mejor_arbol <- select_best(resultados_arbol, metric = "roc_auc")
cat("Mejor cost_complexity:", mejor_arbol$cost_complexity, "\n")
Mejor cost_complexity: 0.02154435 
# Ajustar y evaluar
wf_arbol_final <- finalize_workflow(wf_arbol, mejor_arbol)
ajuste_arbol <- fit(wf_arbol_final, data = datos_train)

pred_arbol <- predict(ajuste_arbol, datos_test) |>
  bind_cols(predict(ajuste_arbol, datos_test, type = "prob")) |>
  bind_cols(datos_test |> select(voto))

# Comparar métricas
cat("Árbol de decisión:\n")
Árbol de decisión:
print(pred_arbol |> metrics(truth = voto, estimate = .pred_class, .pred_si))
# A tibble: 4 × 3
  .metric     .estimator .estimate
  <chr>       <chr>          <dbl>
1 accuracy    binary         0.587
2 kap         binary         0.175
3 mn_log_loss binary         0.696
4 roc_auc     binary         0.589
cat("\nRandom Forest:\n")

Random Forest:
print(metricas_rf)
# A tibble: 4 × 3
  .metric     .estimator .estimate
  <chr>       <chr>          <dbl>
1 accuracy    binary         0.619
2 kap         binary         0.224
3 mn_log_loss binary         0.676
4 roc_auc     binary         0.629
# Visualizar el tuning
autoplot(resultados_arbol) +
  theme_minimal() +
  labs(title = "Tuning del árbol de decisión")

  • El árbol de decisión suele tener menor AUC que Random Forest
  • A cambio, es mucho más interpretable: se puede visualizar como un diagrama de flujo
  • cost_complexity controla la poda: valores más altos producen árboles más simples

Apéndice 4: XGBoost opcional

# boost_tree(): Gradient Boosting (árboles secuenciales)
# tree_depth y learn_rate a optimizar; min_n fijo
modelo_xgb <- boost_tree(
  trees = 500,
  tree_depth = tune(),
  learn_rate = tune(),
  min_n = 10
) |>
  set_engine("xgboost") |>
  set_mode("classification")

wf_xgb <- workflow() |>
  add_recipe(receta) |>
  add_model(modelo_xgb)

# learn_rate en escala log10: 10^-3 a 10^-1
grilla_xgb <- grid_regular(
  tree_depth(range = c(3, 8)),
  learn_rate(range = c(-3, -1)),
  levels = c(3, 3)
)

resultados_xgb <- tune_grid(
  wf_xgb, resamples = folds, grid = grilla_xgb,
  metrics = metric_set(roc_auc),
  control = control_grid(verbose = FALSE)
)

mejor_xgb <- select_best(resultados_xgb, metric = "roc_auc")
wf_xgb_final <- finalize_workflow(wf_xgb, mejor_xgb)
ajuste_xgb <- fit(wf_xgb_final, data = datos_train)

# Predicciones y comparación final
pred_xgb <- predict(ajuste_xgb, datos_test) |>
  bind_cols(predict(ajuste_xgb, datos_test, type = "prob")) |>
  bind_cols(datos_test |> select(voto))

bind_rows(
  pred_logit |> metrics(truth = voto, estimate = .pred_class, .pred_si) |>
    mutate(modelo = "Regresión logística"),
  pred_rf |> metrics(truth = voto, estimate = .pred_class, .pred_si) |>
    mutate(modelo = "Random Forest"),
  pred_xgb |> metrics(truth = voto, estimate = .pred_class, .pred_si) |>
    mutate(modelo = "XGBoost")
) |>
  select(modelo, .metric, .estimate) |>
  pivot_wider(names_from = .metric, values_from = .estimate) |>
  arrange(desc(roc_auc))
# A tibble: 3 × 5
  modelo              accuracy    kap mn_log_loss roc_auc
  <chr>                  <dbl>  <dbl>       <dbl>   <dbl>
1 Regresión logística    0.611 0.214        0.659   0.643
2 Random Forest          0.619 0.224        0.676   0.629
3 XGBoost                0.563 0.0970       0.679   0.603
  • XGBoost suele igualar o superar a Random Forest en datasets tabulares
  • Requiere más tuning (tree_depth, learn_rate, trees) y más tiempo de cómputo
  • En ciencias sociales, la mejora marginal rara vez justifica la pérdida de interpretabilidad