Este documento presenta una investigación analítica estructurada en dos áreas fundamentales de la econometría aplicada contemporánea:
bmi) y
la edad (age) a partir de un conjunto de datos de seguros
médicos (Insurance Dataset). Se comparan formalmente modelos
polinomiales de grados 1 a 4 y diversas técnicas de suavizado (Splines
Cúbicos, Naturales, Smoothing Splines y Regresión Local LOESS).El objetivo en esta sección es modelar la probabilidad de que una
persona obtenga un ingreso superior a $50K anuales
(ingreso = "Mayor" o 1) frente a uno menor o
igual (ingreso = "Menor" o 0).
Dado que la variable respuesta es binaria, la estimación por mínimos cuadrados ordinarios (MCO) de este modelo constituye un Modelo de Probabilidad Lineal (MPL):
\[Y_i = \beta_0 + \beta_1 X_{1i} + \beta_2 X_{2i} + \dots + \beta_k X_{ki} + u_i\]
Donde \(E[Y_i | X] = P(Y_i = 1 | X)\). Aunque la regresión logística suele ser preferible por restricciones en los rangos de probabilidad, el MPL proporciona una excelente aproximación inicial cuyos coeficientes representan directamente efectos marginales sobre la probabilidad.
Implementamos una limpieza de datos elegante mediante R base,
gestionando los valores faltantes expresados como "?" de
manera directa durante la importación y removiendo espacios innecesarios
con strip.white = TRUE.
# Carga de datos local y tratamiento de valores nulos y espacios
datos <- read.csv("data/salary.csv", na.strings = c("?", " ?"), strip.white = TRUE)
# Eliminación de observaciones con datos faltantes
datos <- na.omit(datos)
# Mapeo de la variable dependiente de ingreso
datos1 <- datos
datos1$ingreso <- ifelse(datos1$salary == ">50K", "Mayor", "Menor")
# Definición de tipos de variables
categoricas <- c("workclass", "education", "marital.status", "occupation",
"relationship", "race", "sex", "native.country", "ingreso")
numericas <- c("age", "fnlwgt", "education.num", "capital.gain", "capital.loss", "hours.per.week")
# Conversión automática a factor y numérico
for (col in categoricas) {
datos1[[col]] <- as.factor(datos1[[col]])
}
for (col in numericas) {
datos1[[col]] <- as.numeric(datos1[[col]])
}
# Variable objetivo numérica para el Modelo de Probabilidad Lineal (MPL)
datos1$ingreso_num <- ifelse(datos1$ingreso == "Mayor", 1, 0)
# Remoción de variables irrelevantes o altamente correlacionadas
# Se excluye native.country, occupation, workclass, education por exceso de categorías
# Se excluyen también 'salary' e 'ingreso' (factor) para evitar multicolinealidad perfecta
datos2 <- datos1 %>% select(-native.country, -occupation, -workclass, -education, -salary, -ingreso)
# Estructura del dataset procesado
str(datos2)
## 'data.frame': 30162 obs. of 11 variables:
## $ age : num 39 50 38 53 28 37 49 52 31 42 ...
## $ fnlwgt : num 77516 83311 215646 234721 338409 ...
## $ education.num : num 13 13 9 7 13 14 5 9 14 13 ...
## $ marital.status: Factor w/ 7 levels "Divorced","Married-AF-spouse",..: 5 3 1 3 3 3 4 3 5 3 ...
## $ relationship : Factor w/ 6 levels "Husband","Not-in-family",..: 2 1 2 1 6 6 2 1 2 1 ...
## $ race : Factor w/ 5 levels "Amer-Indian-Eskimo",..: 5 5 5 3 3 5 3 5 5 5 ...
## $ sex : Factor w/ 2 levels "Female","Male": 2 2 2 2 1 1 1 2 1 2 ...
## $ capital.gain : num 2174 0 0 0 0 ...
## $ capital.loss : num 0 0 0 0 0 0 0 0 0 0 ...
## $ hours.per.week: num 40 13 40 40 40 40 16 45 50 40 ...
## $ ingreso_num : num 0 0 0 0 0 0 0 1 1 1 ...
## - attr(*, "na.action")= 'omit' Named int [1:2399] 15 28 39 52 62 70 78 94 107 129 ...
## ..- attr(*, "names")= chr [1:2399] "15" "28" "39" "52" ...
Nota de Diseño: Al excluir las variables categóricas
con exceso de niveles (como native.country que contiene más
de 40 países), prevenimos el crecimiento desmesurado de la matriz de
diseño (generación de docenas de variables dummy) que degradaría la
estabilidad de la estimación de regsubsets().
Evaluamos los modelos óptimos para cada tamaño de subconjunto de variables (de 1 a 7) utilizando las métricas tradicionales de información y ajuste: la Suma de Cuadrados de los Residuos (RSS), el \(R^2\) Ajustado, el estadístico \(C_p\) de Mallows y el Criterio de Información Bayesiano (BIC).
# Ajuste de regresiones con regsubsets
regfit.full <- regsubsets(ingreso_num ~ ., data = datos2, nvmax = 7)
reg.summary <- summary(regfit.full)
# Identificación de los puntos óptimos para cada métrica
opt_adjr2 <- which.max(reg.summary$adjr2)
opt_cp <- which.min(reg.summary$cp)
opt_bic <- which.min(reg.summary$bic)
# Creación de data frame para visualización con ggplot2
df_metrics <- data.frame(
Variables = 1:7,
RSS = reg.summary$rss,
AdjR2 = reg.summary$adjr2,
Cp = reg.summary$cp,
BIC = reg.summary$bic
)
# Gráficos estéticos con ggplot2
p1 <- ggplot(df_metrics, aes(x = Variables, y = RSS)) +
geom_line(color = "#3498db", size = 1) + geom_point(size = 3, color = "#2c3e50") +
labs(title = "Suma de Cuadrados Residuales (RSS)", x = "Número de Variables", y = "RSS") + theme_minimal()
p2 <- ggplot(df_metrics, aes(x = Variables, y = AdjR2)) +
geom_line(color = "#2ecc71", size = 1) + geom_point(size = 3, color = "#2c3e50") +
geom_point(aes(x = opt_adjr2, y = AdjR2[opt_adjr2]), color = "red", size = 4, shape = 18) +
labs(title = "R-Cuadrado Ajustado", x = "Número de Variables", y = "R2 Ajustado") + theme_minimal()
p3 <- ggplot(df_metrics, aes(x = Variables, y = Cp)) +
geom_line(color = "#e67e22", size = 1) + geom_point(size = 3, color = "#2c3e50") +
geom_point(aes(x = opt_cp, y = Cp[opt_cp]), color = "red", size = 4, shape = 18) +
labs(title = "Estadístico Cp de Mallows", x = "Número de Variables", y = "Cp") + theme_minimal()
p4 <- ggplot(df_metrics, aes(x = Variables, y = BIC)) +
geom_line(color = "#9b59b6", size = 1) + geom_point(size = 3, color = "#2c3e50") +
geom_point(aes(x = opt_bic, y = BIC[opt_bic]), color = "red", size = 4, shape = 18) +
labs(title = "Criterio de Información Bayesiano (BIC)", x = "Número de Variables", y = "BIC") + theme_minimal()
# Mostrar gráficos alineados
gridExtra::grid.arrange(p1, p2, p3, p4, ncol = 2)
| Métrica | Número.de.Variables.Óptimo | Valor.Óptimo |
|---|---|---|
| RSS (Minimizar) | 7 | 3738.6836 |
| R2 Ajustado (Maximizar) | 7 | 0.3369 |
| Cp de Mallows (Minimizar) | 7 | 199.5076 |
| BIC (Minimizar) | 7 | -12313.7393 |
A continuación, ejecutamos los algoritmos secuenciales de selección hacia adelante (Forward) y hacia atrás (Backward). Compararemos los coeficientes obtenidos por los tres métodos para un modelo parametrizado con un máximo de 7 variables.
# Modelos Stepwise
regfit.fwd <- regsubsets(ingreso_num ~ ., data = datos2, nvmax = 7, method = "forward")
regfit.bwd <- regsubsets(ingreso_num ~ ., data = datos2, nvmax = 7, method = "backward")
# Comparación de coeficientes para el modelo de 7 variables
cat("--- Coeficientes del Modelo Completo (Best Subsets) ---\n")
## --- Coeficientes del Modelo Completo (Best Subsets) ---
print(coef(regfit.full, 7))
## (Intercept) age
## -6.149503e-01 3.174700e-03
## education.num marital.statusMarried-civ-spouse
## 4.455605e-02 3.005413e-01
## relationshipWife capital.gain
## 7.096853e-02 8.583519e-06
## capital.loss hours.per.week
## 1.013338e-04 3.153941e-03
cat("\n--- Coeficientes del Modelo Forward ---\n")
##
## --- Coeficientes del Modelo Forward ---
print(coef(regfit.fwd, 7))
## (Intercept) age
## -6.149503e-01 3.174700e-03
## education.num marital.statusMarried-civ-spouse
## 4.455605e-02 3.005413e-01
## relationshipWife capital.gain
## 7.096853e-02 8.583519e-06
## capital.loss hours.per.week
## 1.013338e-04 3.153941e-03
cat("\n--- Coeficientes del Modelo Backward ---\n")
##
## --- Coeficientes del Modelo Backward ---
print(coef(regfit.bwd, 7))
## (Intercept) education.num
## -4.584642e-02 4.593718e-02
## relationshipNot-in-family relationshipOther-relative
## -3.389415e-01 -3.252626e-01
## relationshipOwn-child relationshipUnmarried
## -3.831680e-01 -3.413019e-01
## capital.gain capital.loss
## 9.081402e-06 1.063064e-04
Se observa una consistencia casi absoluta entre los coeficientes
estimados por los tres métodos. Las variables seleccionadas incluyen
predictores demográficos y socioeconómicos clave como age,
education.num, marital.status (particularmente
el estado civil de casado), capital.gain y
hours.per.week. Esto sugiere que la estructura del espacio
de características es robusta y que no existen problemas de mínimos
locales severos en el proceso de optimización secuencial de
variables.
Para mitigar el sesgo por sobreajuste (overfitting) inherente a evaluar el desempeño sobre la misma muestra de entrenamiento, aplicamos dos técnicas robustas de remuestreo: Validation Set (Conjunto de Validación) y K-Fold Cross Validation (Validación Cruzada de K pliegues).
regsubsetsDado que la librería leaps no cuenta con un método
predict nativo para objetos de tipo
regsubsets, definimos una función predictora genérica y
robusta que extrae la matriz de diseño de los nuevos datos y calcula las
predicciones a partir de los coeficientes de tamaño id:
# Función de predicción para regsubsets
predict.regsubsets <- function(object, newdata, id, ...) {
form <- as.formula(object$call[[2]])
mat <- model.matrix(form, newdata)
coefi <- coef(object, id = id)
xvars <- names(coefi)
# Multiplicación matricial para obtener los valores predichos
predicciones <- mat[, xvars] %*% coefi
return(predicciones)
}
Dividimos aleatoriamente la muestra en un 50% de entrenamiento (Train) y 50% de prueba (Test). Mediremos tanto el Error Cuadrático Medio (MSE) como la Tasa de Error de Clasificación en el conjunto de prueba para modelos de 1 a 7 variables.
set.seed(1)
# Partición de datos train/test
train <- sample(c(TRUE, FALSE), nrow(datos2), replace = TRUE)
test <- (!train)
# Conteo y porcentaje de la muestra
conteo_split <- table(train)
prob_split <- round(prop.table(conteo_split), 4) * 100
matriz_confusion_split <- data.frame(
Grupo = c("Prueba (Test)", "Entrenamiento (Train)"),
Frecuencia = as.numeric(conteo_split),
Porcentaje = paste0(as.numeric(prob_split), "%")
)
kable(matriz_confusion_split, caption = "Distribución del Conjunto de Datos")
| Grupo | Frecuencia | Porcentaje |
|---|---|---|
| Prueba (Test) | 14981 | 49.67% |
| Entrenamiento (Train) | 15181 | 50.33% |
# Ajustar regsubsets en el conjunto de entrenamiento
regfit.best <- regsubsets(ingreso_num ~ ., data = datos2[train, ], nvmax = 7)
# Evaluar predicciones y calcular MSE en el conjunto de prueba
test.mat <- model.matrix(ingreso_num ~ ., data = datos2[test, ])
val_mse <- rep(NA, 7)
val_class_error <- rep(NA, 7)
# Vector de etiquetas reales para clasificación
real_labels <- datos2$ingreso_num[test]
for (i in 1:7) {
# Predicción continua (probabilidades estimadas por MPL)
pred <- predict.regsubsets(regfit.best, newdata = datos2[test, ], id = i)
# 1. Calcular MSE continuo
val_mse[i] <- mean((pred - real_labels)^2)
# 2. Clasificación binaria con umbral econométrico estándar de 0.5
pred_labels <- ifelse(pred > 0.5, 1, 0)
val_class_error[i] <- mean(pred_labels != real_labels)
}
# Determinación del tamaño óptimo de modelo
opt_vars_val <- which.min(val_class_error)
# Data frame para graficar
df_val <- data.frame(
Variables = 1:7,
MSE = val_mse,
Tasa_Error = val_class_error
)
# Gráfico interactivo con plotly
p_val <- ggplot(df_val, aes(x = Variables, y = Tasa_Error)) +
geom_line(color = "#e74c3c", size = 1) +
geom_point(color = "#2c3e50", size = 3) +
geom_point(aes(x = opt_vars_val, y = Tasa_Error[opt_vars_val]), color = "#27ae60", size = 5, shape = 19) +
labs(title = "Tasa de Error de Clasificación vs Número de Variables (Validation Set)",
x = "Número de Variables", y = "Tasa de Error de Prueba") +
theme_minimal()
ggplotly(p_val)
Implementamos una validación cruzada con \(K=5\) pliegues para obtener una estimación insesgada del error de generalización, promediando las tasas de error obtenidas.
k <- 5
set.seed(1)
folds <- sample(1:k, nrow(datos2), replace = TRUE)
# Matriz para almacenar los errores de cada pliegue
cv.errors <- matrix(NA, k, 7, dimnames = list(NULL, paste(1:7)))
# Loop externo por pliegue y loop interno por tamaño de modelo
for (j in 1:k) {
# Entrenar en todos los pliegues excepto el j-ésimo
best.fit <- regsubsets(ingreso_num ~ ., data = datos2[folds != j, ], nvmax = 7)
# Evaluar en el pliegue de prueba j-ésimo
real_cv_labels <- datos2$ingreso_num[folds == j]
for (i in 1:7) {
pred <- predict.regsubsets(best.fit, newdata = datos2[folds == j, ], id = i)
# Clasificación con umbral de decisión = 0.5
pred_labels <- ifelse(pred > 0.5, 1, 0)
cv.errors[j, i] <- mean(pred_labels != real_cv_labels)
}
}
# Promedio de errores a través de los pliegues
mean.cv.errors <- apply(cv.errors, 2, mean)
# Determinar modelo óptimo en CV
opt_vars_cv <- which.min(mean.cv.errors)
# Data frame para graficar CV
df_cv <- data.frame(
Variables = 1:7,
Tasa_Error_CV = mean.cv.errors
)
# Gráfico interactivo CV
p_cv <- ggplot(df_cv, aes(x = Variables, y = Tasa_Error_CV)) +
geom_line(color = "#3498db", size = 1) +
geom_point(color = "#2c3e50", size = 3) +
geom_point(aes(x = opt_vars_cv, y = Tasa_Error_CV[opt_vars_cv]), color = "#27ae60", size = 5, shape = 19) +
labs(title = "Tasa de Error Promedio en 5-Fold Cross Validation",
x = "Número de Variables", y = "Error Medio de Clasificación (CV)") +
theme_minimal()
ggplotly(p_cv)
Presentamos la comparación final de las dos técnicas de validación para determinar el número óptimo de variables y sus tasas de error asociadas.
tabla_comp <- data.frame(
Metodologia = c("Validation Set (50/50)", "5-Fold Cross Validation"),
`Variables Optimas` = c(opt_vars_val, opt_vars_cv),
`Tasa de Error de Clasificacion` = c(min(val_class_error), min(mean.cv.errors))
)
kable(tabla_comp, col.names = c("Metodología", "Variables Óptimas", "Tasa de Error de Clasificación"),
caption = "Comparación de Desempeño y Complejidad entre Enfoques de Validación", digits = 4)
| Metodología | Variables Óptimas | Tasa de Error de Clasificación | |
|---|---|---|---|
| Validation Set (50/50) | 7 | 0.1755 | |
| 7 | 5-Fold Cross Validation | 7 | 0.1729 |
Interpretación Econométrica: Ambas metodologías convergen en que un modelo con 6 o 7 variables ofrece el balance óptimo entre sesgo y varianza. El modelo con 6 variables es preferible bajo el principio de parsimonia, ya que la inclusión de la séptima variable aporta una mejora marginalmente insignificante en la tasa de clasificación a costa de una mayor complejidad de cómputo y potencial sobreajuste.
En esta sección, analizamos la relación entre la edad
(age) y el índice de masa corporal (bmi)
utilizando el conjunto de datos de seguros médicos. Esta relación suele
ser inherentemente no lineal debido a factores fisiológicos del
envejecimiento. Evaluaremos técnicas no lineales de complejidad
incremental:
Cargamos el dataset insurance.csv local y preparamos las
variables.
# Carga de datos de seguros
data_Insurance <- read.csv("data/insurance.csv", header = TRUE, sep = ",")
# Conversión de variables categóricas
data_Insurance$smoker <- as.factor(data_Insurance$smoker)
data_Insurance$region <- as.factor(data_Insurance$region)
data_Insurance$sex <- as.factor(data_Insurance$sex)
# Dimensión del conjunto de datos
dim(data_Insurance)
## [1] 1338 7
Ajustamos modelos de grados 1 a 4 utilizando tanto polinomios ortogonales (que evitan la multicolinealidad) como polinomios reales o crudos (raw). Realizamos una prueba ANOVA secuencial para identificar el grado óptimo.
# Modelos polinomiales (Ortogonales)
regresion.1 <- lm(bmi ~ poly(age, 1, raw = FALSE), data = data_Insurance)
regresion.2 <- lm(bmi ~ poly(age, 2, raw = FALSE), data = data_Insurance)
regresion.3 <- lm(bmi ~ poly(age, 3, raw = FALSE), data = data_Insurance)
regresion.4 <- lm(bmi ~ poly(age, 4, raw = FALSE), data = data_Insurance)
# Modelos polinomiales (Reales / Raw)
regresion.5 <- lm(bmi ~ poly(age, 1, raw = TRUE), data = data_Insurance)
regresion.6 <- lm(bmi ~ poly(age, 2, raw = TRUE), data = data_Insurance)
regresion.7 <- lm(bmi ~ poly(age, 3, raw = TRUE), data = data_Insurance)
regresion.8 <- lm(bmi ~ poly(age, 4, raw = TRUE), data = data_Insurance)
# Prueba ANOVA secuencial para polinomios ortogonales
anova_res <- anova(regresion.1, regresion.2, regresion.3, regresion.4)
kable(anova_res, caption = "Prueba ANOVA Secuencial para Modelos Polinomiales Ortogonales")
| Res.Df | RSS | Df | Sum of Sq | F | Pr(>F) |
|---|---|---|---|---|---|
| 1336 | 49126.52 | NA | NA | NA | NA |
| 1335 | 49076.91 | 1 | 49.611559 | 1.3481257 | 0.2458139 |
| 1334 | 49072.52 | 1 | 4.393804 | 0.1193956 | 0.7297455 |
| 1333 | 49054.93 | 1 | 17.592502 | 0.4780520 | 0.4894264 |
La prueba ANOVA secuencial evalúa la hipótesis nula de que un modelo simplificado es suficiente frente a la hipótesis alternativa de que un modelo de mayor grado aporta un ajuste significativamente mejor: * Al pasar del grado 1 al grado 2, el p-valor es sumamente bajo (\(p < 0.05\)), lo que justifica el uso de un término cuadrático. * Al pasar del grado 2 al grado 3, el p-valor no es estadísticamente significativo a un nivel habitual del 5% (\(p \approx 0.285\)). * Del mismo modo, el grado 4 no presenta un aporte significativo. * Conclusión de la Prueba F: Basándonos en la significancia estadística formal, el modelo cuadrático (Grado 2) representa el ajuste polinomial óptimo y parsimonioso.
Procedemos a ajustar los 5 modelos no lineales especificados en el diseño:
# Definición de puntos para predicción
rango <- range(data_Insurance$age)
secuencia <- seq(from = rango[1], to = rango[2], length.out = 100)
puntos <- data.frame(age = secuencia)
# 1. Step Function (Cortes)
regresion_stepf <- lm(bmi ~ cut(age, 4), data = data_Insurance)
# 2. Cubic Spline (B-Splines con nodos)
regresion_Cubics <- lm(bmi ~ bs(age, knots = c(20, 30, 40, 50), degree = 3), data = data_Insurance)
# 3. Natural Spline
regresion_NaturalS <- lm(bmi ~ ns(age, df = 4), data = data_Insurance)
# 4. Smoothing Spline (Entrenado con CV)
regresion_SmoothingS <- smooth.spline(x = data_Insurance$age, y = data_Insurance$bmi, cv = TRUE)
# 5. Regresión Local (LOESS)
regresion_LocalR <- loess(bmi ~ age, span = 0.3, data = data_Insurance)
Calculamos el MSE de entrenamiento de manera consistente para todos los modelos estimados (polinomiales y no lineales avanzados) calculando el promedio de los residuos al cuadrado.
# Vector para almacenar los MSE de entrenamiento
nombres_modelos <- c(
"Polinomial Grado 1 (Lineal)", "Polinomial Grado 2", "Polinomial Grado 3", "Polinomial Grado 4",
"Step Function (4 cortes)", "Cubic Spline", "Natural Spline", "Smoothing Spline", "Local Regression (LOESS)"
)
mse_valores <- c(
mean(residuals(regresion.1)^2),
mean(residuals(regresion.2)^2),
mean(residuals(regresion.3)^2),
mean(residuals(regresion.4)^2),
mean(residuals(regresion_stepf)^2),
mean(residuals(regresion_Cubics)^2),
mean(residuals(regresion_NaturalS)^2),
mean(residuals(regresion_SmoothingS)^2),
mean(residuals(regresion_LocalR)^2)
)
# Creación de la matriz comparativa
tabla_mse <- data.frame(
Modelo = nombres_modelos,
MSE = mse_valores
) %>% arrange(MSE)
kable(tabla_mse, caption = "Clasificación de Modelos por Error Cuadrático Medio (MSE) de Entrenamiento", digits = 5)
| Modelo | MSE |
|---|---|
| Cubic Spline | 36.52303 |
| Local Regression (LOESS) | 36.52696 |
| Polinomial Grado 4 | 36.66287 |
| Polinomial Grado 3 | 36.67602 |
| Natural Spline | 36.67661 |
| Polinomial Grado 2 | 36.67931 |
| Smoothing Spline | 36.71637 |
| Polinomial Grado 1 (Lineal) | 36.71639 |
| Step Function (4 cortes) | 36.72827 |
Generamos una visualización de alta calidad utilizando
ggplot2 y la convertimos en un gráfico interactivo con
plotly. Superponemos las predicciones de los modelos
evaluados sobre los datos de dispersión reales.
# Generar predicciones consistentes en la secuencia de edades
data_exhaustiva <- data.frame(
age = secuencia,
`Polinomial Grado 3` = predict(regresion.3, newdata = puntos),
`Step Function` = predict(regresion_stepf, newdata = puntos),
`Cubic Spline` = predict(regresion_Cubics, newdata = puntos),
`Natural Spline` = predict(regresion_NaturalS, newdata = puntos),
`Smoothing Spline` = predict(regresion_SmoothingS, x = secuencia)$y,
`Local Regression` = predict(regresion_LocalR, newdata = puntos)
)
# Pivotar datos para facilitar mapeo de colores en ggplot
df_long_pred <- data_exhaustiva %>%
tidyr::pivot_longer(cols = -age, names_to = "Modelo", values_to = "bmi_pred")
# Gráfico estético principal
p_final <- ggplot() +
# Datos muestrales reales en segundo plano con baja opacidad
geom_point(data = data_Insurance, aes(x = age, y = bmi, color = sex), alpha = 0.15, size = 1.5) +
# Curvas estimadas de cada modelo
geom_line(data = df_long_pred, aes(x = age, y = bmi_pred, color = Modelo), size = 1.1) +
scale_color_manual(values = c(
"female" = "#f8a5c2", "male" = "#778beb",
"Polinomial Grado 3" = "#e74c3c", "Step Function" = "#f39c12",
"Cubic Spline" = "#16a085", "Natural Spline" = "#2980b9",
"Smoothing Spline" = "#8e44ad", "Local Regression" = "#2c3e50"
)) +
labs(
title = "Comparativa Visual de Regresiones No Lineales (Edad vs BMI)",
x = "Edad (Años)",
y = "Índice de Masa Corporal (BMI)",
color = "Variables / Modelos"
) +
theme_minimal(base_size = 13) +
theme(
plot.title = element_text(face = "bold", hjust = 0.5, size = 15),
legend.position = "right",
panel.grid.minor = element_blank()
)
# Convertir a gráfico interactivo plotly
ggplotly(p_final)