En este caso práctico se se realizan predicciónes de aprobación de credito bancario del set de datos Credit Aprroval Data Set del UC Irvine Machine Learning Repository. La información sobre el set de datos se encuentra en este enlace, Se emplean los modelos de selección automática de parámetros Ridge regression y LASSO.
knitr::opts_chunk$set(message = FALSE, warning = FALSE, fig.align = "center", table.align = "center", echo = TRUE)library(tidyverse)
library(here)
library(xray)
library(gridExtra)
library(missForest)
library(forcats)
library(FSelector)
library(glmnet)
library(caret)
library(knitr)
library(kableExtra)
library(broom)
library(influence.ME)datos <-read.csv(here::here("datos", "CASO_FINAL_crx.data"))# renombramos columnas
encabezados <- paste0("A", 1:16)
names(datos) <- encabezados
kable(summary(datos)) %>% kable_styling(font_size = 11, full_width = T) %>%
row_spec(0, color = "white", background = "steelblue")| A1 |
|
| A4 | A5 |
|
|
| A9 | A10 |
| A12 | A13 |
|
| A16 | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| ?: 12 | ? : 12 | Min. : 0.000 | ?: 6 | ? : 6 | c :137 | v :398 | Min. : 0.000 | f:329 | f:395 | Min. : 0.000 | f:373 | g:624 | 00000 :132 | Min. : 0 | -:383 | |
| a:210 | 22.67 : 9 | 1st Qu.: 1.000 | l: 2 | g :518 | q : 78 | h :138 | 1st Qu.: 0.165 | t:360 | t:294 | 1st Qu.: 0.000 | t:316 | p: 8 | 00120 : 35 | 1st Qu.: 0 | +:306 | |
| b:467 | 20.42 : 7 | Median : 2.750 | u:518 | gg: 2 | w : 63 | bb : 59 | Median : 1.000 | NA | NA | Median : 0.000 | NA | s: 57 | 00200 : 35 | Median : 5 | NA | |
| NA | 18.83 : 6 | Mean : 4.766 | y:163 | p :163 | i : 59 | ff : 57 | Mean : 2.225 | NA | NA | Mean : 2.402 | NA | NA | 00160 : 34 | Mean : 1019 | NA | |
| NA | 19.17 : 6 | 3rd Qu.: 7.250 | NA | NA | aa : 54 | ? : 9 | 3rd Qu.: 2.625 | NA | NA | 3rd Qu.: 3.000 | NA | NA | 00080 : 30 | 3rd Qu.: 396 | NA | |
| NA | 20.67 : 6 | Max. :28.000 | NA | NA | ff : 53 | j : 8 | Max. :28.500 | NA | NA | Max. :67.000 | NA | NA | 00100 : 30 | Max. :100000 | NA | |
| NA | (Other):643 | NA | NA | NA | (Other):245 | (Other): 20 | NA | NA | NA | NA | NA | NA | (Other):393 | NA | NA |
Observamos en primer lugar, que hay algunos datos etiquetados con “?”, que corresponden a valores perdidos, los convertiremos en la nomenclatura de valores perdidos para R
# valores perdidos están identificados con "?". Cambiamos a valor perdido por defecto de R
datos <- datos %>%
na_if("?")
# eliminaicon de nivel "?" de factors
datos[c("A1", "A4", "A5", "A6", "A7", "A14")] <- map(datos[c("A1", "A4", "A5", "A6", "A7", "A14")], ~fct_drop(.x, "?"))Hay algunas variables que al contener el caracter “?” se han detectado como factores a pesar de ser numéricas. Procedemos al cambio de clase.
map_chr(datos, class)## A1 A2 A3 A4 A5 A6 A7
## "factor" "factor" "numeric" "factor" "factor" "factor" "factor"
## A8 A9 A10 A11 A12 A13 A14
## "numeric" "factor" "factor" "integer" "factor" "factor" "factor"
## A15 A16
## "integer" "factor"
datos[c("A2", "A14")] <- map(datos[c("A2", "A14")], ~as.numeric(.x))
map_chr(datos, class)## A1 A2 A3 A4 A5 A6 A7
## "factor" "numeric" "numeric" "factor" "factor" "factor" "factor"
## A8 A9 A10 A11 A12 A13 A14
## "numeric" "factor" "factor" "integer" "factor" "factor" "numeric"
## A15 A16
## "integer" "factor"
Para facilitar la obtencion de resultados tras utilizar los algoritmos de prediccion, cambiamos la nomenclatura de las categorías de la variable predictora, A16, a 0 para no aprobación de crédito, y 1 para aprobación de crédito.
datos <- datos %>%
mutate(A16 = as.factor(if_else(A16 == "-", 0, 1)))A11 y A15 tienen un alto porcentaje de ceros. En principio no es algo que deba resultar preocupante.
kable(anomalies(datos)$variables) %>% kable_styling(font_size = 11,
bootstrap_options = c("striped", "hover", "condensed", "responsive"),
position = "right") %>%
row_spec(0, color = "white", background = "steelblue") %>%
row_spec(c(1,3),background = "#FBF3A9") %>%
column_spec(1, bold = T)| Variable | q | qNA | pNA | qZero | pZero | qBlank | pBlank | qInf | pInf | qDistinct | type | anomalous_percent |
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| A11 | 689 | 0 |
|
395 | 57.33% | 0 |
|
0 |
|
23 | Integer | 57.33% |
| A16 | 689 | 0 |
|
383 | 55.59% | 0 |
|
0 |
|
2 | Factor | 55.59% |
| A15 | 689 | 0 |
|
294 | 42.67% | 0 |
|
0 |
|
240 | Integer | 42.67% |
| A8 | 689 | 0 |
|
70 | 10.16% | 0 |
|
0 |
|
132 | Numeric | 10.16% |
| A3 | 689 | 0 |
|
18 | 2.61% | 0 |
|
0 |
|
215 | Numeric | 2.61% |
| A14 | 689 | 13 | 1.89% | 0 |
|
0 |
|
0 |
|
170 | Numeric | 1.89% |
| A1 | 689 | 12 | 1.74% | 0 |
|
0 |
|
0 |
|
3 | Factor | 1.74% |
| A2 | 689 | 12 | 1.74% | 0 |
|
0 |
|
0 |
|
349 | Numeric | 1.74% |
| A7 | 689 | 9 | 1.31% | 0 |
|
0 |
|
0 |
|
10 | Factor | 1.31% |
| A6 | 689 | 9 | 1.31% | 0 |
|
0 |
|
0 |
|
15 | Factor | 1.31% |
| A4 | 689 | 6 | 0.87% | 0 |
|
0 |
|
0 |
|
4 | Factor | 0.87% |
| A5 | 689 | 6 | 0.87% | 0 |
|
0 |
|
0 |
|
4 | Factor | 0.87% |
| A9 | 689 | 0 |
|
0 |
|
0 |
|
0 |
|
2 | Factor |
|
| A10 | 689 | 0 |
|
0 |
|
0 |
|
0 |
|
2 | Factor |
|
| A12 | 689 | 0 |
|
0 |
|
0 |
|
0 |
|
2 | Factor |
|
| A13 | 689 | 0 |
|
0 |
|
0 |
|
0 |
|
3 | Factor |
|
Usaremos una función auxiliar para graficar en varios paneles la distribución de las variables explicativas contra la variable dependiente
grafica_por_tipo <- function(datos, var) {
# character -> ggplot
# Dibuja un grafico dada una variable "A16" presente en un data.frame, contra otra de las variables del midmo data.frame.
# El tipo de grafico depende del tipo de variable
# ASUME: el data.frame contiene la variable "A16"
# aislamiento de la variable elegida para detectar suclase
vector_var <- datos[ ,var]
# guardamos grafico de barras si la variable explicativa es factor
if (is.factor(vector_var)) {
g <- datos %>%
ggplot(aes_string(x = var)) +
geom_bar(aes(fill = A16), position = "dodge") # dividimos A16 por color
}
else {
# guardamos diagrama de caja si la variable explicativa es numerica
g <- datos %>%
ggplot(aes(x = A16)) +
geom_boxplot(aes_string(y = var, fill = "A16")) # una caja para cada uno de los valores de A16
}
# dibujo y formateo del grafico
g + labs(x = "", y = "", fill = "", title = var) + # etiquetas de ejes y titulo
scale_fill_manual(values = c("orange", "steelblue")) + # colores para los valores de a16
theme_bw() + # tema generico del grafico
# establecimiento de tamaño de fuentes
theme(plot.title = element_text(size = 9),
axis.text.y = element_text(size = 6))
}
# creacion de lista de graficos de A16 contra el resto de variables
lista_graficos <- map(paste0("A", 1:15), ~grafica_por_tipo(datos, .x))
# dibujo de los gráficos en un panel de 5 columnas
grid.arrange(grobs = lista_graficos, ncol = 5,
top = "Distribución de las variables condicionada a A16")Las variables con más influencia sobre la dependiente parecen ser la A9 y la A10. El calculo de la ganancia de información confirma la influencia de estas dos variables. Pero aparecen también en lugar destacado las variables A11 y A15, cuya influencia pasa desapercibida en el gráfico debido a que hay presencia de valores extremos. Se da el caso de que son las mismas variables con una grán presencia de ceros.
grafica_ganancia_informacion <- function(datos) {
# data.frame -> ggplot
# grafica en un diagrama de barras la ganancia de informacion de las variables de un data.frame al desagruparlas
# por la variable A!6
# ASUME: el data.frame contiene la variable A16
# calculo de ganancia de informacion y formato del data.frame resultante
information.gain(A16~., data = datos) %>%
mutate(variables = rownames(.)) %>%
select(variables, attr_importance) %>%
# generacion del grafico
ggplot(aes(x = fct_reorder(variables, attr_importance), y = attr_importance)) +
# columnas
geom_col(fill = "steelblue", alpha = 0.8) +
# particion de columnas con linea blanca
geom_hline(yintercept = seq(0, 0.3, by = 0.05), color = "white") +
# inversion de coordenadas para convertir columnas en barras
coord_flip() +
# adicion de etiquetas numericas con ganancia de información
geom_text(aes(label = round(attr_importance, 3)), nudge_y = 0.015, color = "grey45") +
# Formato del grafico
labs(x = "Variables", y = "Ganancia de informacion",
title = "Ganancia de información al desagrupar las variables independientes por A16") +
theme_bw() +
theme(axis.line = element_line(color = "black"),
axis.text = element_text(),
panel.border = element_blank(),
legend.background = element_rect(size = 1),
legend.key = element_rect(size = 0.2),
panel.grid = element_blank(),
plot.subtitle = element_text(color = "darkgoldenrod4"))
}
# ejecucion de la funcion anterior para el data.frame "datos"
grafica_ganancia_informacion(datos) Si graficamos A11 y A15 tomando en una escala logarítmica se observa mejor la influencia de la variable A16.
lista_graf_extremos <- map(c("A11", "A15"),
~grafica_por_tipo(datos, .x) +
scale_y_log10() +
theme(plot.subtitle = element_text(size = 6)))
grid.arrange(grobs = lista_graf_extremos, ncol = 2,
top = "Distribución de variables con valores extremos condicionadas a A16 en escala logarítmica")Usamos un bosque aleatorio para la imputación de los valores perdidos, ya que las funciones para los algoritmos de clasificacion Ridge y LASSO no admiten valores perdidos
# imputacion de valores
datos_completos <- missForest(datos)$ximp ## missForest iteration 1 in progress...done!
## missForest iteration 2 in progress...done!
## missForest iteration 3 in progress...done!
## missForest iteration 4 in progress...done!
## missForest iteration 5 in progress...done!
## missForest iteration 6 in progress...done!
## missForest iteration 7 in progress...done!
## missForest iteration 8 in progress...done!
En primer lugar dividimos el data set en un training set y un test set
train <- datos_completos[1:590,]
test <- datos_completos[591:690, ]A continuación preparamos los datos para la estimación
x_train <- data.matrix(train[, 1:15 ])
y_train <- data.matrix(train$A16)
x_test <- data.matrix(test[, 1:15])
# preparando y test en formato 0 y 1
y_test <- data.matrix(test$A16)Construimos los modelos Ridge y lasso. Hacemos tres particiones para cada valor de lambda en el cross validation.
# establecimiento de 3 folds para el cross validation
n_folds <- 3
# fijando seed para elatoriedad
set.seed(123)
cv_ridge <- cv.glmnet(x_train, y_train, family='binomial', alpha=0,
parallel=TRUE, standardize=TRUE, type.measure='auc',
nfolds = n_folds)
cv_lasso <- cv.glmnet(x_train, y_train, family='binomial', alpha=1,
parallel=TRUE, standardize=TRUE, type.measure='auc',
nfolds = n_folds)Calculamos el mejor AUC (Area Under de Curve). Tanto la estimación puntual como el error estandar del AUC estimado
# estimacion del mejor AUC en cada modelo
auc_ridge <- max(cv_ridge$cvm)
auc_lasso <- max(cv_lasso$cvm)
# Estimacion del error estandar del mejor AUC
se_auc_ridge <- cv_ridge$cvsd[which.max(cv_ridge$cvm)]/sqrt(n_folds)
se_auc_lasso <- cv_lasso$cvsd[which.max(cv_lasso$cvm)]/sqrt(n_folds)
resultados_auc <- data.frame("AUC" = c("V. esperado", "Err. estandar"),
"Ridge" = round(c(auc_ridge, se_auc_ridge), 3),
"LASSO" = round(c(auc_lasso, se_auc_lasso), 3)
)
# row.names(resultados_auc) <- c("AUC", "SE")
kable(resultados_auc) %>% kable_styling(full_width = FALSE, bootstrap_options = "condensed") %>%
row_spec(0, color = "white", background = "steelblue")| AUC | Ridge | LASSO |
|---|---|---|
| V. esperado | 0.910 | 0.907 |
| Err. estandar | 0.008 | 0.010 |
Ambos modelos dan resultados muy similares. Aunque el modelo con Ridge tiene mejor AUC estimado, los modelos están a menos de un error estandar de distancia en cuanto a la estimación de esta medida. Por principio de parsimonia escogemos el modelo LASSO, que es que incluye menos variables.
Para el modelo de lasso, los resultados no cambian significativamente hasta que la penalización, \(\lambda\), alcanza valores relativamente elevados. Consecuentemente el modelo final contempla solamente cuatro varibles con un efecto significativo sobre A16.
plot(cv_lasso)y_pred_lasso <- as.numeric(predict.glmnet(cv_lasso$glmnet.fit, newx=x_test, s=cv_lasso$lambda.min)>.5)
conf_matrix <- confusionMatrix(as.factor(y_pred_lasso), as.factor(y_test), mode = "everything", positive = "1")
conf_matrix## Confusion Matrix and Statistics
##
## Reference
## Prediction 0 1
## 0 83 6
## 1 3 7
##
## Accuracy : 0.9091
## 95% CI : (0.8344, 0.9576)
## No Information Rate : 0.8687
## P-Value [Acc > NIR] : 0.1475
##
## Kappa : 0.5583
## Mcnemar's Test P-Value : 0.5050
##
## Sensitivity : 0.53846
## Specificity : 0.96512
## Pos Pred Value : 0.70000
## Neg Pred Value : 0.93258
## Precision : 0.70000
## Recall : 0.53846
## F1 : 0.60870
## Prevalence : 0.13131
## Detection Rate : 0.07071
## Detection Prevalence : 0.10101
## Balanced Accuracy : 0.75179
##
## 'Positive' Class : 1
##
Los log odds son los coeficientes del modelo, porque los modelos logísticos son lineales en el logaritmo del odds
# Extraccion de los coeficientes del mejor modelo en formato data.frame
logodds <- tidy(coef(cv_lasso, s = cv_lasso$lambda.min))[c(1, 3)]
names(logodds) <- c("Variable", "Beta")
logodds$Beta <- round(logodds$Beta, 4)
# generacion de tabla
kable(logodds) %>% kable_styling(full_width = F, bootstrap_options = "condensed") %>%
row_spec(0, color = "white", background = "steelblue")| Variable | Beta |
|---|---|
| (Intercept) | -5.0663 |
| A9 | 2.5684 |
| A10 | 0.5743 |
| A11 | 0.0130 |
| A15 | 0.0000 |
Vemos como hay variables que no aparecen. Son las variables a las que el modelo ha asignado un coeficiente de 0.
La interpretación del los coeficientes entonces, se lee en términos del odds ratio. Por ejemplo, el coeficiente de A9 nos dice que al incrementar el valor de esta variable en una unidad, el odds ratio se multiplica por \(e^{2.5684} = 13.04\)
Por cada verdadero positivo, hay una ganacia de 100 €. Por cada falso positivo hay una pérdida de 20 €.
Un verdadero positivo es la sensibilidad. 1 - falso positivo es la especificidad. Calculamos a partir de aquí, y los resultados del modelo, la rentabilidad del modelo.
sensibilidad <- round(conf_matrix$byClass["Sensitivity"], 3)
especificidad <- round(conf_matrix$byClass["Specificity"], 3)
rent_esp <- sensibilidad*100 - especificidad*20
names(rent_esp) <- NULL\[R = FPR \cdot 100 - FPR \cdot 20 = 34.5\]
La rentabilidad esperada es de 34.5 € por caso
El modelo final tiene una precisión elevada pero esto se debe en parte a la asimetría de los datos en el test set. Un 0.86 de los valores para A16 en el test set son 0. Y es ahí precisamente donde el modelo se comporta bien. El modelo tiene una especifidad de 0.965: cuando un caso es negativo, lo predice correctamente un 96.5% de las veces. En cambio, cuando un caso es positivo, solo lo precide correctamente un 53.8% de las veces.
Obviamente, la selección no aleatoria del test set es un problema. Es un problema manifiesto porque la distribución de la variable A16 en el test set no parece ser representativa de la población.Se debería reformular el problema con una selección aleatoria del training y el test set.
bind_rows(
tibble(A16 = as.factor(y_train), Set = "Train"),
tibble(A16 = as.factor(y_test), Set = "Test")
) %>%
filter(!is.na(A16)) %>%
mutate(
Set = fct_drop(Set),
Set = fct_relevel(Set, c("Train", "Test"))
) %>%
ggplot(aes(x = Set)) +
geom_bar(aes(fill = A16), position = "dodge", width = 0.6) +
scale_fill_manual(values = c("orange", "steelblue")) +
labs(x = "Set",
y = "Frecuencia",
title = "Disparidad en la distribución de A16 en train y test set") +
theme_classic() +
theme()La presencia de valores perdidos tambien es un problema potencial que debería examinarse. Una resolución alternativa del problema omitiendo algunos de los valores extremos podría dar resultados diferentes.