IA para Científicos Sociales

Sesión 2.4: Laboratorio 4 - Regresión y regularización

Danilo Freire

Department of Data and Decision Sciences
Emory University

Laboratorio 4: Regresión y regularización

Objetivos del laboratorio

Retomamos la clase de regresión. Hoy ejecutamos paso a paso la misma receta que vimos en la teoría:

  1. Preparar los datos con una receta
  2. Ajustar un baseline OLS
  3. Tunear LASSO con validación cruzada
  4. Implementar Ridge por analogía (Ejercicio 1)
  5. Comparar los tres modelos
  6. Decidir: ¿predicción o explicación? (Ejercicio 2)

Lo que vamos a aprender:

  • Cuándo y cómo usar regularización
  • Diferencias prácticas entre LASSO y Ridge
  • Tuning de penalty con validación cruzada
  • Trade-off entre interpretabilidad y rendimiento


La mayoría de los slides son demostraciones: corremos el código juntos. Solo hay 2 ejercicios, ambos con solución en el apéndice 🤓

Parte 1: Preparación

Cargar paquetes y datos

# Si algún paquete falta: install.packages(c("tidymodels", "tidyverse", "glmnet"))
library(tidymodels)  # rsample, parsnip, recipes, workflows, tune, yardstick
library(tidyverse)   # incluye readr (read_csv), dplyr, ggplot2, etc.
library(glmnet)      # motor para LASSO, Ridge, Elastic Net

set.seed(2026)

# Cargar el dataset
datos <- read_csv("datos/latinobarometro_sim.csv", show_col_types = FALSE)
# Lean el archivo directo desde la web:
# datos <- read_csv("https://raw.githubusercontent.com/danilofreire/introduccion-ia-ucu/main/clases/dia-02/datos/latinobarometro_sim.csv", show_col_types = FALSE)

# Convertir categóricas a factor
datos <- datos |>
  mutate(
    pais         = factor(pais),
    zona         = factor(zona),
    genero       = factor(genero),
    uso_internet = factor(uso_internet, levels = c("nunca", "semanal", "diario"))
  )

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                    <chr> "no", "si", "si", "no", "si", "si", "no", "si"…

Dividir los datos

# División train/test (75/25), estratificada por el outcome
# (con una variable numérica, rsample estratifica por cuartiles)
datos_split <- initial_split(datos, prop = 0.75, strata = satisfaccion_vida)
datos_train <- training(datos_split)
datos_test  <- testing(datos_split)

cat("Train:", nrow(datos_train), "obs | Test:", nrow(datos_test), "obs\n")
Train: 374 obs | Test: 126 obs
  • La variable objetivo es satisfaccion_vida (1-10, continua)
  • Train tendrá ~375 obs, test ~125

Receta de preprocesamiento

# Predecir con todas las variables menos voto (outcome del Lab 3) y pais (alta cardinalidad)
receta <- recipe(satisfaccion_vida ~ ., data = datos_train) |>
  step_rm(pais, voto) |>                       # eliminar variables no predictivas o con demasiados niveles
  step_dummy(all_nominal_predictors()) |>      # categóricas → dummies
  step_normalize(all_numeric_predictors()) |>  # normalizar (importante para regularización)
  step_zv(all_predictors())                    # eliminar varianza cero

# Verificar
# prep() = preparar la receta con los datos de train; juice() = extraer el dataset preprocesado
receta |> prep() |> juice() |> glimpse()
Rows: 374
Columns: 13
$ edad                    <dbl> 1.56520710, -1.36378561, 0.58887619, -0.798541…
$ educacion_anios         <dbl> -0.01407531, -0.25335565, -0.73191632, -0.7319…
$ ingreso_hogar           <dbl> -1.0474034, -0.6102060, -1.4846008, -0.6102060…
$ confianza_gobierno      <dbl> -0.3354074, 0.7947040, -0.3354074, -0.3354074,…
$ confianza_justicia      <dbl> 0.4016346, 0.4016346, 0.4016346, -1.6420570, 0…
$ satisfaccion_democracia <dbl> -0.5613683, 1.6486502, -0.5613683, 0.5436409, …
$ percepcion_economia     <dbl> 1.6921109, 1.6921109, 0.8287419, 0.8287419, 1.…
$ interes_politica        <dbl> 0.5350528, 1.5457080, -0.4756025, 0.5350528, 0…
$ satisfaccion_vida       <dbl> 5.3, 5.4, 4.3, 5.5, 5.3, 5.4, 4.4, 5.7, 5.5, 5…
$ zona_urbana             <dbl> 0.5621869, 0.5621869, 0.5621869, 0.5621869, -1…
$ genero_mujer            <dbl> 1.0148141, 1.0148141, -0.9827674, 1.0148141, -…
$ uso_internet_semanal    <dbl> -0.6115532, -0.6115532, -0.6115532, -0.6115532…
$ uso_internet_diario     <dbl> 0.8355407, 0.8355407, 0.8355407, 0.8355407, 0.…

Parte 2: OLS baseline

OLS: spec y ajuste

# Regresión lineal con motor lm
modelo_ols <- linear_reg() |>
  set_engine("lm") |>
  set_mode("regression")

# Workflow + ajuste (combinados en una pipeline)
ajuste_ols <- workflow() |>
  add_recipe(receta) |>
  add_model(modelo_ols) |>
  fit(data = datos_train)

# Coeficientes ordenados por magnitud (top 5)
tidy(ajuste_ols) |>
  arrange(desc(abs(estimate))) |>
  head(5)
# A tibble: 5 × 5
  term                    estimate std.error statistic   p.value
  <chr>                      <dbl>     <dbl>     <dbl>     <dbl>
1 (Intercept)                6.69     0.0609    110.   1.66e-279
2 confianza_gobierno         0.320    0.0684      4.69 3.96e-  6
3 educacion_anios            0.310    0.0741      4.18 3.61e-  5
4 ingreso_hogar              0.290    0.0742      3.91 1.12e-  4
5 satisfaccion_democracia    0.276    0.0622      4.43 1.23e-  5
  • ajuste_ols ya tiene el modelo ajustado a datos_train
  • En el próximo slide lo evaluamos en test

Evaluar OLS en test

# Generar predicciones (augment() añade .pred al test set)
pred_ols <- augment(ajuste_ols, datos_test)

# Calcular métricas en test
metricas_ols <- pred_ols |>
  metrics(truth = satisfaccion_vida, estimate = .pred)

metricas_ols |> knitr::kable(digits = 3)
.metric .estimator .estimate
rmse standard 1.286
rsq standard 0.225
mae standard 1.018
  • RMSE ≈ 1.3: en promedio la predicción se aleja de la realidad en ~1.3 puntos (escala 1-10)
  • R² ≈ 0.23: el modelo explica ~23% de la varianza de la satisfacción

Parte 3: LASSO con tuning

Definir LASSO + grilla de búsqueda

# LASSO: linear_reg con mixture = 1 (L1 puro)
# tune() = "este valor se busca con CV"
modelo_lasso <- linear_reg(penalty = tune(), mixture = 1) |>
  set_engine("glmnet") |>
  set_mode("regression")

wf_lasso <- workflow() |> add_recipe(receta) |> add_model(modelo_lasso)

# Grilla de 30 valores de λ en escala logarítmica (10^-4 = 0.0001 a 10^0 = 1)
grilla_lambda <- grid_regular(penalty(range = c(-4, 0)), levels = 30)

head(grilla_lambda)
# A tibble: 6 × 1
   penalty
     <dbl>
1 0.0001  
2 0.000137
3 0.000189
4 0.000259
5 0.000356
6 0.000489
  • mixture = 1 → LASSO puro. Si fuera 0, sería Ridge (lo ven en el Ejercicio 1)
  • tune() deja penalty (λ) pendiente: lo elige la validación cruzada

Tuning de LASSO con CV

# Crear folds (10-fold CV)
folds <- vfold_cv(datos_train, v = 10)

# Tuning: probar cada valor de la grilla con CV
resultados_lasso <- tune_grid(
  wf_lasso,
  resamples = folds,
  grid      = grilla_lambda,
  metrics   = metric_set(rmse)
)

# Top 5 mejores λ por RMSE
resultados_lasso |>
  collect_metrics() |>
  filter(.metric == "rmse") |>
  arrange(mean) |>
  head(5) |>
  knitr::kable(digits = 4)
penalty .metric .estimator mean n std_err .config
0.0161 rmse standard 1.1916 10 0.0559 pre0_mod17_post0
0.0221 rmse standard 1.1916 10 0.0560 pre0_mod18_post0
0.0117 rmse standard 1.1920 10 0.0559 pre0_mod16_post0
0.0085 rmse standard 1.1924 10 0.0559 pre0_mod15_post0
0.0062 rmse standard 1.1927 10 0.0559 pre0_mod14_post0
  • mean es el RMSE promedio sobre los 10 folds; std_err indica cuánto varía entre folds

Visualizar el tuning de LASSO

autoplot(resultados_lasso) +
  scale_x_log10() +
  theme_minimal() +
  labs(title = "RMSE vs. λ (escala log)")

  • La curva suele tener forma de U: λ muy bajo → poca regularización (sobreajuste); λ muy alto → demasiada (subajuste)
  • El mínimo de la curva es el λ óptimo según CV

Seleccionar λ y ajuste final

# 1. Seleccionar el λ con menor RMSE
lambda_min <- select_best(resultados_lasso, metric = "rmse")

# 2. Finalizar el workflow con ese λ y ajustar a todo el train
ajuste_lasso <- wf_lasso |>
  finalize_workflow(lambda_min) |>
  fit(data = datos_train)

# Métricas en test (las reutilizamos en la tabla comparativa)
pred_lasso     <- augment(ajuste_lasso, datos_test)
metricas_lasso <- pred_lasso |>
  metrics(truth = satisfaccion_vida, estimate = .pred)

# 3. Coeficientes y conteo de variables eliminadas
coef_lasso <- tidy(ajuste_lasso) |>
  filter(term != "(Intercept)") |>
  arrange(desc(abs(estimate)))

cat("Variables eliminadas (coef = 0):",
    sum(coef_lasso$estimate == 0), "de", nrow(coef_lasso), "\n")
Variables eliminadas (coef = 0): 1 de 12 
coef_lasso |> head(8) |> knitr::kable(digits = 3)
term estimate penalty
confianza_gobierno 0.307 0.016
educacion_anios 0.302 0.016
ingreso_hogar 0.277 0.016
satisfaccion_democracia 0.260 0.016
percepcion_economia 0.227 0.016
edad 0.129 0.016
zona_urbana -0.063 0.016
interes_politica 0.057 0.016

Comparar coeficientes OLS vs. LASSO

coef_ols  <- tidy(ajuste_ols)   |> filter(term != "(Intercept)") |> select(term, OLS   = estimate)
coef_lasc <- tidy(ajuste_lasso) |> filter(term != "(Intercept)") |> select(term, LASSO = estimate)

left_join(coef_ols, coef_lasc, by = "term") |>
  pivot_longer(c(OLS, LASSO), names_to = "modelo", values_to = "coef") |>
  ggplot(aes(x = reorder(term, abs(coef)), y = coef, fill = modelo)) +
  geom_col(position = "dodge") + coord_flip() +
  scale_fill_manual(values = c("OLS" = "#3498DB", "LASSO" = "#E74C3C")) +
  labs(title = "Coeficientes: OLS vs. LASSO",
       x = NULL, y = "Coeficiente (normalizado)") +
  theme_minimal()

  • Es la versión práctica de “interpretar los coeficientes de LASSO” que vimos en la clase: LASSO encoge coeficientes hacia cero (algunos exactamente cero); OLS los deja todos sin restricción

Parte 4: Ridge por analogía

Ejercicio 1: Implementar Ridge por analogía

Tarea (5 min): adapten el pipeline de LASSO que acabamos de ver. Cambien solo lo necesario para Ridge: completen los tres huecos

# 1. Modelo Ridge: igual que LASSO pero mixture = 0
modelo_ridge <- linear_reg(penalty = tune(), mixture = ___) |>  # TODO: ¿qué valor?
  set_engine("glmnet") |>
  set_mode("regression")

# 2. Workflow + tuning (reutilizamos folds y grilla_lambda)
wf_ridge <- workflow() |> add_recipe(receta) |> add_model(modelo_ridge)

resultados_ridge <- tune_grid(
  ___, resamples = folds, grid = grilla_lambda,    # TODO: ¿qué workflow?
  metrics = metric_set(rmse)
)

# 3. Seleccionar mejor λ, finalizar, ajustar
lambda_ridge <- select_best(___, metric = "rmse")  # TODO: ¿qué resultados?
ajuste_ridge <- finalize_workflow(wf_ridge, lambda_ridge) |>
  fit(data = datos_train)

# 4. Evaluar en test
pred_ridge     <- augment(ajuste_ridge, datos_test)
metricas_ridge <- pred_ridge |>
  metrics(truth = satisfaccion_vida, estimate = .pred)

# 5. Contar variables eliminadas (debería ser 0!)
cat("Variables eliminadas en Ridge:",
    sum(tidy(ajuste_ridge)$estimate[-1] == 0), "\n")
metricas_ridge

Nota

Pista: el único cambio conceptual respecto a LASSO es mixture = 0. Los objetos folds y grilla_lambda ya están definidos; reutilícenlos tal cual

Tip

Checkpoint: Ridge debería eliminar 0 variables (puede mostrar valores como 1e-10, prácticamente cero pero no exactos). RMSE similar a LASSO (~1.1-1.3)

Apéndice 1: Solución

LASSO selecciona, Ridge encoge

LASSO (L1)

  • Penaliza la suma de valores absolutos
  • Reduce algunos coeficientes a exactamente cero
  • Hace selección automática de variables
  • Útil cuando solo algunas variables importan

Ridge (L2)

  • Penaliza la suma de cuadrados
  • Reduce todos los coeficientes pero ninguno a cero exacto
  • Mantiene todas las variables
  • Útil con variables correlacionadas (multicolinealidad)


Geométricamente: la “bola” de L1 tiene esquinas que tocan los ejes (coeficiente = 0); la “bola” de L2 es lisa y nunca toca exactamente un eje.

Para una versión intermedia (Elastic Net), ver Apéndice 5

Parte 5: Comparación final

Tabla comparativa: OLS, LASSO y Ridge

# Combinar las métricas de los 3 modelos (ya creadas en los slides anteriores)
tabla_final <- bind_rows(
  metricas_ols   |> mutate(modelo = "OLS"),
  metricas_lasso |> mutate(modelo = "LASSO"),
  metricas_ridge |> mutate(modelo = "Ridge")
) |>
  select(modelo, .metric, .estimate) |>
  pivot_wider(names_from = .metric, values_from = .estimate) |>
  arrange(rmse)

tabla_final |> knitr::kable(digits = 3)
modelo rmse rsq mae
Ridge 1.281 0.229 1.012
LASSO 1.283 0.226 1.016
OLS 1.286 0.225 1.018
  • Los 3 modelos producen RMSE muy similares en este dataset
  • LASSO selecciona variables; Ridge las encoge; OLS no penaliza

Ejercicio 2: ¿Predicción o explicación?

Tarea (5 min): miren la tabla comparativa y el gráfico de coeficientes OLS vs. LASSO, y discutan en grupo

  1. ¿Qué variables redujo LASSO a cero? ¿Tiene sentido sustantivo que esas pesen menos?
  2. Si el objetivo es explicar qué determina la satisfacción con la vida, ¿qué modelo prefieren? ¿Y si solo quieren predecirla para casos nuevos?
  3. ¿Por qué las diferencias de RMSE entre los tres modelos son tan pequeñas con estos datos?

Nota

Pista: vuelvan a la distinción de la clase entre predicción y explicación. Un modelo más simple (menos variables) es más fácil de explicar, aunque prediga casi igual

Apéndice 2: Discusión

Cierre

Discusión y resumen

Lo que vimos hoy:

  1. Ejecutar la receta completa: receta → modelo → workflow
  2. Tunear LASSO con CV y seleccionar λ
  3. Implementar Ridge por analogía (Ejercicio 1)
  4. Comparar OLS, LASSO y Ridge
  5. Decidir entre predicción y explicación (Ejercicio 2)

Conceptos clave:

  • mixture = 1LASSO (selecciona)
  • mixture = 0Ridge (encoge)
  • tune() + tune_grid() para CV
  • select_best() + finalize_workflow() para fijar λ
  • En este dataset las diferencias entre OLS/LASSO/Ridge son marginales


¿Por qué tan marginales? Porque las relaciones son aproximadamente lineales y el ratio n/p no es extremo. La regularización brilla cuando p ≫ n o hay colinealidad fuerte.

Para llevarse a casa: en el Apéndice 4 apliquen todo el pipeline a un outcome diferente (satisfaccion_democracia)

Apéndice: Soluciones

Apéndice 1: Solución Ejercicio 1

# Cambio único: mixture = 0 (Ridge en vez de LASSO)
modelo_ridge <- linear_reg(penalty = tune(), mixture = 0) |>
  set_engine("glmnet") |>
  set_mode("regression")

wf_ridge <- workflow() |> add_recipe(receta) |> add_model(modelo_ridge)

resultados_ridge <- tune_grid(
  wf_ridge, resamples = folds, grid = grilla_lambda,
  metrics = metric_set(rmse)
)

lambda_ridge <- select_best(resultados_ridge, metric = "rmse")
ajuste_ridge <- finalize_workflow(wf_ridge, lambda_ridge) |>
  fit(data = datos_train)

pred_ridge     <- augment(ajuste_ridge, datos_test)
metricas_ridge <- pred_ridge |>
  metrics(truth = satisfaccion_vida, estimate = .pred)

cat("Variables eliminadas en Ridge:",
    sum(tidy(ajuste_ridge)$estimate[-1] == 0), "\n")
metricas_ridge

Métricas de Ridge en test:

.metric .estimator .estimate
rmse standard 1.281
rsq standard 0.229
mae standard 1.012
  • Ridge nunca elimina variables, solo las encoge; el conteo de eliminadas es 0
  • RMSE similar al de LASSO en este dataset

Volver al ejercicio

Apéndice 2: Discusión Ejercicio 2

No hay una única respuesta correcta. Algunas ideas:

  • Variables eliminadas: LASSO suele llevar a cero las de menor señal en estos datos (por ejemplo algunas dummies de zona o genero). Que pesen poco es plausible: no son los grandes determinantes de la satisfacción
  • ¿Explicar o predecir? Si el objetivo es explicar, conviene un modelo simple e interpretable: OLS o LASSO (LASSO además deja una lista corta de variables). Si el objetivo es predecir casos nuevos, elegimos por RMSE en test, sin importar cuántas variables queden
  • Diferencias pequeñas de RMSE: con relaciones casi lineales y n mayor que el número de predictores, hay poco sobreajuste que corregir. La regularización aporta más cuando hay muchas variables o colinealidad fuerte

Volver al ejercicio

Apéndice 3: Dos criterios para elegir λ (opcional)

En la clase vimos dos formas de elegir λ. Hasta ahora usamos select_best (mínimo RMSE). La regla 1-SE elige el λ más grande (modelo más simple) que queda a menos de un error estándar del mínimo:

# Criterio 1: mínimo RMSE (el que usamos en el lab)
lambda_min  <- select_best(resultados_lasso, metric = "rmse")

# Criterio 2: regla de un error estándar (modelo más parsimonioso)
lambda_1se  <- select_by_one_std_err(resultados_lasso, metric = "rmse", desc(penalty))

bind_rows(
  lambda_min |> mutate(criterio = "min"),
  lambda_1se |> mutate(criterio = "1-SE")
)
  • lambda_1se suele ser mayor, así que regulariza más y deja menos variables
  • Sacrifica un poco de RMSE a cambio de un modelo más simple de explicar

Volver a la sección de discusión

Apéndice 4: Capstone: outcome diferente (opcional)

Para llevarse a casa: apliquen todo el pipeline a satisfaccion_democracia (escala 1-5). ¿Cómo cambian los resultados?

# 1. Nueva receta para predecir satisfaccion_democracia
receta_dem <- recipe(satisfaccion_democracia ~ ., data = datos_train) |>
  step_rm(pais, voto, satisfaccion_vida) |>      # excluir outcomes ajenos
  step_dummy(all_nominal_predictors()) |>
  step_normalize(all_numeric_predictors()) |>
  step_zv(all_predictors())

# 2. LASSO con la nueva receta (mismo patrón)
wf_lasso_dem <- workflow() |> add_recipe(receta_dem) |> add_model(modelo_lasso)

resultados_dem <- tune_grid(
  wf_lasso_dem, resamples = folds, grid = grilla_lambda,
  metrics = metric_set(rmse)
)

lambda_dem  <- select_best(resultados_dem, metric = "rmse")
ajuste_dem  <- finalize_workflow(wf_lasso_dem, lambda_dem) |>
  fit(data = datos_train)

# 3. Evaluación
pred_dem <- augment(ajuste_dem, datos_test)

pred_dem |> metrics(truth = satisfaccion_democracia, estimate = .pred)

Preguntas para reflexionar:

  • ¿Las mismas variables son predictivas, o cambian?
  • ¿El RMSE es mayor o menor que para satisfaccion_vida?
  • Pueden repetir con percepcion_economia como tercer outcome

Volver a la sección de discusión

Apéndice 5: Elastic Net (opcional)

Elastic Net combina LASSO y Ridge: mixture entre 0 y 1

# Ajustar AMBOS hiperparámetros: penalty (λ) y mixture (α)
modelo_enet <- linear_reg(penalty = tune(), mixture = tune()) |>
  set_engine("glmnet") |>
  set_mode("regression")

wf_enet <- workflow() |> add_recipe(receta) |> add_model(modelo_enet)

# Grilla 2D: 20 valores de λ × 5 de mixture (como en la clase)
grilla_enet <- grid_regular(
  penalty(range = c(-4, 0)),
  mixture(range = c(0, 1)),
  levels = c(20, 5)
)

resultados_enet <- tune_grid(
  wf_enet, resamples = folds, grid = grilla_enet,
  metrics = metric_set(rmse)
)

mejor_enet  <- select_best(resultados_enet, metric = "rmse")
mejor_enet  # vean el mixture óptimo

ajuste_enet <- finalize_workflow(wf_enet, mejor_enet) |>
  fit(data = datos_train)

predict(ajuste_enet, datos_test) |>
  bind_cols(datos_test |> select(satisfaccion_vida)) |>
  metrics(truth = satisfaccion_vida, estimate = .pred)
  • Si mixture óptimo está cerca de 1 → LASSO ganó
  • Si está cerca de 0 → Ridge ganó
  • Si intermedio → Elastic Net mejora sobre ambos

Volver a la sección de LASSO vs. Ridge

¡Fin del Día 2! 🤓