--- title: "IA para Científicos Sociales" subtitle: "Sesión 2.4: Laboratorio 4 - Regresión y regularización" author: - name: Danilo Freire orcid: 0000-0002-4712-6810 email: danilofreire@gmail.com affiliations: "Department of Data and Decision Sciences
Emory University" format: clean-revealjs: self-contained: true footer: "[Sesión 2.4](https://danilofreire.github.io/introduccion-ia-ucu/clases/dia-02/08-laboratorio-04.html)" transition: slide transition-speed: default scrollable: true execute: echo: true revealjs-plugins: - multimodal engine: knitr editor: render-on-save: true lang: es --- ```{r setup, include=FALSE} options(htmltools.dir.version = FALSE) library(knitr) opts_chunk$set( prompt = FALSE, fig.align = "center", dpi = 300, cache = T, engine.opts = list(bash = "-l") ) knit_hooks$set( prompt = function(before, options, envir) { options( prompt = if (options$engine %in% c("sh", "bash", "zsh")) "$ " else "R> ", continue = if (options$engine %in% c("sh", "bash", "zsh")) "$ " else "+ " ) } ) options(repos = c(CRAN = "https://cran.rstudio.com/")) if (!require("fontawesome", character.only = TRUE)) { install.packages("fontawesome", dependencies = TRUE) library(fontawesome, character.only = TRUE) } ``` # Laboratorio 4: Regresión y regularización {background-color="#2d4563"} ## Objetivos del laboratorio :::{style="margin-top: 30px; font-size: 24px;"} :::{.columns} :::{.column width=50%} **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]{.alert} 3. Tunear [LASSO]{.alert} con validación cruzada 4. Implementar [Ridge]{.alert} por analogía (**Ejercicio 1**) 5. Comparar los tres modelos 6. Decidir: ¿predicción o explicación? (**Ejercicio 2**) ::: :::{.column width=50%} **Lo que vamos a aprender:** - Cuándo y cómo usar [regularización]{.alert} - Diferencias prácticas entre [LASSO]{.alert} y [Ridge]{.alert} - 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]{.alert} 🤓 ::: ::: ::: # Parte 1: Preparación {background-color="#2d4563"} ## Cargar paquetes y datos :::{style="margin-top: 30px; font-size: 20px;"} ```{r} #| label: setup-paquetes #| message: false #| warning: false #| eval: true # 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) ``` ::: ## Dividir los datos :::{style="margin-top: 30px; font-size: 24px;"} ```{r} #| label: split-datos #| eval: true # 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") ``` - La variable objetivo es `satisfaccion_vida` (1-10, continua) - Train tendrá ~375 obs, test ~125 ::: ## Receta de preprocesamiento :::{style="margin-top: 30px; font-size: 20px;"} ```{r} #| label: receta #| eval: true # 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() ``` ::: # Parte 2: OLS baseline {background-color="#2d4563"} ## OLS: spec y ajuste :::{style="margin-top: 30px; font-size: 22px;"} ```{r} #| label: ols-ajuste #| eval: true # 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) ``` - `ajuste_ols` ya tiene el modelo ajustado a `datos_train` - En el próximo slide lo evaluamos en test ::: ## Evaluar OLS en test :::{style="margin-top: 30px; font-size: 24px;"} ```{r} #| label: ols-eval #| eval: true # 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) ``` :::{style="margin-top:30px;"} - 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 {background-color="#2d4563"} ## Definir LASSO + grilla de búsqueda :::{style="margin-top: 30px; font-size: 22px;"} ```{r} #| label: lasso-spec #| eval: true # 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) ``` - `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 :::{style="margin-top: 30px; font-size: 19px;"} ```{r} #| label: lasso-cv #| eval: true # 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) ``` - `mean` es el RMSE promedio sobre los 10 folds; `std_err` indica cuánto varía entre folds ::: ## Visualizar el tuning de LASSO :::{style="margin-top: 30px; font-size: 22px;"} ```{r} #| label: lasso-viz #| eval: true #| fig-width: 9 #| fig-height: 4 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 :::{style="margin-top: 30px; font-size: 18px;"} ```{r} #| label: lasso-final #| eval: true # 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") coef_lasso |> head(8) |> knitr::kable(digits = 3) ``` ::: ## Comparar coeficientes OLS vs. LASSO :::{style="margin-top: 30px; font-size: 19px;"} ```{r} #| label: comparar-coef #| eval: true #| fig-width: 10 #| fig-height: 4 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 {background-color="#2d4563"} ## Ejercicio 1: Implementar Ridge por analogía {#sec:exercise01} :::{style="margin-top: 30px; font-size: 18px;"} **Tarea (5 min):** adapten el pipeline de LASSO que acabamos de ver. Cambien solo lo necesario para Ridge: completen los tres huecos ```{r} #| label: ej1-skeleton #| eval: false # 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 ``` ```{r} #| label: ej1-hidden #| include: false 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) ``` :::{.callout-note} **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 ::: :::{.callout-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]{.button}](#sec:appendix01) ::: ## LASSO selecciona, Ridge encoge {#sec:lasso-vs-ridge} :::{style="margin-top: 30px; font-size: 22px;"} :::{.columns} :::{.column width=50%} **LASSO (L1)** - Penaliza la suma de [valores absolutos]{.alert} - Reduce algunos coeficientes a [exactamente cero]{.alert} - Hace [selección automática]{.alert} de variables - Útil cuando solo algunas variables importan ::: :::{.column width=50%} **Ridge (L2)** - Penaliza la suma de [cuadrados]{.alert} - Reduce todos los coeficientes pero ninguno a cero exacto - [Mantiene todas las variables]{.alert} - Ú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.]{.alert} Para una versión intermedia (Elastic Net), ver [[Apéndice 5]{.button}](#sec:appendix05) ::: # Parte 5: Comparación final {background-color="#2d4563"} ## Tabla comparativa: OLS, LASSO y Ridge :::{style="margin-top: 30px; font-size: 24px;"} ```{r} #| label: tabla-comparativa #| eval: true # 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) ``` - 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? {#sec:exercise02} :::{style="margin-top: 30px; font-size: 24px;"} **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]{.alert} qué determina la satisfacción con la vida, ¿qué modelo prefieren? ¿Y si solo quieren [predecirla]{.alert} para casos nuevos? 3. ¿Por qué las diferencias de RMSE entre los tres modelos son tan pequeñas con estos datos? :::{.callout-note} **Pista**: vuelvan a la distinción de la clase entre [predicción]{.alert} y [explicación]{.alert}. Un modelo más simple (menos variables) es más fácil de explicar, aunque prediga casi igual ::: [[Apéndice 2: Discusión]{.button}](#sec:appendix02) ::: # Cierre {background-color="#2d4563"} ## Discusión y resumen {#sec:discussion} :::{style="margin-top: 30px; font-size: 22px;"} :::{.columns} :::{.column width=50%} **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) ::: :::{.column width=50%} **Conceptos clave:** - `mixture = 1` → [LASSO]{.alert} (selecciona) - `mixture = 0` → [Ridge]{.alert} (encoge) - `tune()` + `tune_grid()` para CV - `select_best()` + `finalize_workflow()` para fijar λ - En este dataset las diferencias entre OLS/LASSO/Ridge son [marginales]{.alert} ::: :::
[¿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.]{.alert} Para llevarse a casa: en el [[Apéndice 4]{.button}](#sec:appendix04) apliquen todo el pipeline a un outcome diferente (`satisfaccion_democracia`) ::: # Apéndice: Soluciones {background-color="#2d4563"} ## Apéndice 1: Solución Ejercicio 1 {#sec:appendix01} :::{style="margin-top: 20px; font-size: 18px;"} ```{r} #| label: ap1-sol #| eval: false # 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:** ```{r} #| label: ap1-display #| echo: false #| eval: true metricas_ridge |> knitr::kable(digits = 3) ``` - Ridge nunca elimina variables, solo las encoge; el conteo de eliminadas es 0 - RMSE similar al de LASSO en este dataset [[Volver al ejercicio]{.button}](#sec:exercise01) ::: ## Apéndice 2: Discusión Ejercicio 2 {#sec:appendix02} :::{style="margin-top: 20px; font-size: 20px;"} **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]{.alert}, conviene un modelo simple e interpretable: OLS o LASSO (LASSO además deja una lista corta de variables). Si el objetivo es [predecir]{.alert} 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]{.button}](#sec:exercise02) ::: ## Apéndice 3: Dos criterios para elegir λ (opcional) {#sec:appendix03} :::{style="margin-top: 20px; font-size: 19px;"} En la clase vimos dos formas de elegir λ. Hasta ahora usamos `select_best` (mínimo RMSE). La regla [1-SE]{.alert} elige el λ más grande (modelo más simple) que queda a menos de un error estándar del mínimo: ```{r} #| label: ap3-onese #| eval: false # 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]{.button}](#sec:discussion) ::: ## Apéndice 4: Capstone: outcome diferente (opcional) {#sec:appendix04} :::{style="margin-top: 20px; font-size: 17px;"} **Para llevarse a casa:** apliquen todo el pipeline a `satisfaccion_democracia` (escala 1-5). ¿Cómo cambian los resultados? ```{r} #| label: ap4-sol #| eval: false # 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]{.button}](#sec:discussion) ::: ## Apéndice 5: Elastic Net (opcional) {#sec:appendix05} :::{style="margin-top: 20px; font-size: 18px;"} Elastic Net combina LASSO y Ridge: `mixture` entre 0 y 1 ```{r} #| label: ap5-enet #| eval: false # 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]{.button}](#sec:lasso-vs-ridge) ::: # ¡Fin del Día 2! 🤓 {background-color="#2d4563"}