6  Árboles de decisión y bosques aleatorios

Los árboles de decisión son modelos de aprendizaje supervisado sencillos que además son fáciles de interpretar. Los árboles crecen desde la raíz, que contiene todos los casos del ejemplo de entrenamiento, hasta las hojas, que contienen los casos clasificados. En cada nodo del árbol se realiza una división del conjunto de casos del nodo en función de una característica del conjunto de datos, de manera que para cada valor de la característica se obtiene un subconjunto de casos que presentan ese valor. El objetivo es dividir los datos de tal manera que las instancias en cada hoja sean lo más homogéneas posible con respecto a la variable respuesta. Aunque su uso más habitual es para problemas de clasificación, también pueden ser utilizados para problemas de regresión.

En esta práctica también veremos los bosques aleatorios, que son un conjunto de árboles de decisión entrenados con diferentes subconjuntos de datos y características. Los bosques aleatorios son una técnica de ensamblaje que mejora la precisión y la robustez de los modelos de árboles de decisión individuales.

6.1 Ejercicios Resueltos

Para la realización de esta práctica se requieren los siguientes paquetes:

library(tidyverse) 
# Incluye los siguientes paquetes:
# - readr: para la lectura de ficheros csv. 
# - dplyr: para el preprocesamiento y manipulación de datos.
# - ggplot2: para la visualización de datos.
library(tidymodels)
# Incluye los siguientes paquetes:
# - recipes: para la preparación de los datos. 
# - parsnip: para la creación de modelos.
# - workflows: para la creación de flujos de trabajo.
# - rsample: para la creación de particiones de los datos.
# - yardstick: para la evaluación de modelos.
# - tune: para la optimización de hiperparámetros.
library(skimr) # para el análisis exploratorio de datos.
library(rpart.plot) # para la visualización de árboles de decisión.
library(parallel) # para el entrenamiento en paralelo de modelos.
library(ranger) # para la creación de modelos de bosque aleatorio.
library(vip) # para la visualización de la importancia de las variables.
library(knitr) # para el formateo de tablas.

Ejercicio 6.1 El fichero infartos.csv contiene información sobre distintas variables fisiológicas relacionadas con el riesgo de infarto de una muestra de personas. Las variables que contienen son:

  • Edad: Edad del paciente (años)
  • Sexo: Sexo del paciente (H: hombre, M: mujer)
  • DolorPecho: Tipo de dolor torácico (TA: angina típica, ATA: angina atípica, NAP: dolor no anginoso, ASY: asintomático)
  • PresionArterial: Presión arterial sistólica en reposo (mm Hg)
  • Colesterol: Colesterol sérico (mm/dl)
  • Glucemia: Glucemia en ayunas (1: si glucemia en ayunas > 120 mg/dl, 0: de lo contrario)
  • Electro: resultados del electrocardiograma en reposo (Normal: normal, ST: anomalía onda ST-T (inversiones de onda T y/o elevación o depresión de ST > 0,05 mV), LVH: hipertrofia ventricular izquierda probable o definitiva según criterios de Estes)
  • Pulsaciones: Frecuencia cardíaca máxima alcanzada (valor numérico entre 60 y 202)
  • AnginaEjercicio: Angina inducida por ejercicio (S: sí, N: no)
  • DepresionST: Depresión del segmento ST inducida por el ejercicio (valor numérico de la depresión).
  • PendienteST: Pendiente del segmento ST en el pico de ejercicio (Ascendente, Plano, Descencdente).
  • Infarto: Riesgo de infarto (1: Sí, 0: No)
  1. Cargar los datos del archivo infartos.csv en un data frame.

    library(tidyverse)
    # Cargamos los datos del fichero infartos.csv en un data frame.
    df <- read.csv("https://aprendeconalf.es/aprendizaje-automatico-practicas-r/datos/infartos.csv", stringsAsFactors = TRUE)
    # Mostramos un resumen del data frame.
    glimpse(df)
    Rows: 918
    Columns: 12
    $ Edad            <int> 40, 49, 37, 48, 54, 39, 45, 54, 37, 48, 37, 58, 39, 49…
    $ Sexo            <fct> H, M, H, M, H, H, M, H, H, M, M, H, H, H, M, M, H, M, …
    $ DolorPecho      <fct> ATA, NAP, ATA, ASY, NAP, NAP, ATA, ATA, ASY, ATA, NAP,…
    $ PresionArterial <int> 140, 160, 130, 138, 150, 120, 130, 110, 140, 120, 130,…
    $ Colesterol      <int> 289, 180, 283, 214, 195, 339, 237, 208, 207, 284, 211,…
    $ Glucemia        <int> 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, …
    $ Electro         <fct> Normal, Normal, ST, Normal, Normal, Normal, Normal, No…
    $ Pulsaciones     <int> 172, 156, 98, 108, 122, 170, 170, 142, 130, 120, 142, …
    $ AnginaEjercicio <fct> N, N, N, S, N, N, N, N, S, N, N, S, N, S, N, N, N, N, …
    $ DepresionST     <dbl> 0.0, 1.0, 0.0, 1.5, 0.0, 0.0, 0.0, 0.0, 1.5, 0.0, 0.0,…
    $ PendienteST     <fct> Ascendente, Plano, Ascendente, Plano, Ascendente, Asce…
    $ Infarto         <int> 0, 1, 0, 1, 0, 0, 0, 0, 1, 0, 0, 1, 0, 1, 0, 0, 1, 0, …
  2. Convertir la variable Infarto a un factor con dos niveles: Sí (1) y No (0).

    # Recodificamos los valores de Infarto y lo convertimos en un factor.
    df <- df |> mutate(Infarto = factor(case_match(Infarto,
        0 ~ "No",
        1 ~ "Sí"
    )))
  3. Realizar un análisis exploratorio de los datos. ¿Qué variables son numéricas y cuáles categóricas? ¿Hay valores perdidos? ¿Qué tipo de variables son las que contienen información sobre el riesgo de infarto?

    library(skimr)
    # Realizamos un análisis exploratorio de los datos.
    skim(df)
    Data summary
    Name df
    Number of rows 918
    Number of columns 12
    _______________________
    Column type frequency:
    factor 6
    numeric 6
    ________________________
    Group variables None

    Variable type: factor

    skim_variable n_missing complete_rate ordered n_unique top_counts
    Sexo 0 1 FALSE 2 H: 725, M: 193
    DolorPecho 0 1 FALSE 4 ASY: 496, NAP: 203, ATA: 173, TA: 46
    Electro 0 1 FALSE 3 Nor: 552, LVH: 188, ST: 178
    AnginaEjercicio 0 1 FALSE 2 N: 547, S: 371
    PendienteST 0 1 FALSE 3 Pla: 460, Asc: 395, Des: 63
    Infarto 0 1 FALSE 2 Sí: 508, No: 410

    Variable type: numeric

    skim_variable n_missing complete_rate mean sd p0 p25 p50 p75 p100 hist
    Edad 0 1 53.51 9.43 28.0 47.00 54.0 60.0 77.0 ▁▅▇▆▁
    PresionArterial 0 1 132.40 18.51 0.0 120.00 130.0 140.0 200.0 ▁▁▃▇▁
    Colesterol 0 1 198.80 109.38 0.0 173.25 223.0 267.0 603.0 ▃▇▇▁▁
    Glucemia 0 1 0.23 0.42 0.0 0.00 0.0 0.0 1.0 ▇▁▁▁▂
    Pulsaciones 0 1 136.81 25.46 60.0 120.00 138.0 156.0 202.0 ▁▃▇▆▂
    DepresionST 0 1 0.89 1.07 -2.6 0.00 0.6 1.5 6.2 ▁▇▆▁▁
  4. Dividir el conjunto de datos en dos subconjuntos, uno de entrenamiento y otro de test. Utilizar el 80% de los datos para entrenamiento y el 20% restante para test.

    Utilizar la función initial_split del paquete rsample para dividir el conjunto de datos en entrenamiento y test.

    Parámetros:
    • data: el data frame con los datos.
    • prop: la proporción del conjunto de datos que se utilizará para el conjunto de entrenamiento (en este caso, 0.8 para el 80%).
    • strata: la variable de estratificación (en este caso, Especie) para asegurar que la distribución de clases se mantenga en ambos conjuntos.
    library(tidymodels)
    # Establecemos la semilla para la reproducibilidad.
    set.seed(123)
    # Dividimos el conjunto de datos en entrenamiento (80%) y test (20%) estratificando por la variable Infarto.
    df_particion <- initial_split(df, prop = 0.8, strata = Infarto)
    # Extraemos el conjunto de datos de entrenamiento.
    df_entrenamiento <- training(df_particion)
    # Extraemos el conjunto de datos de test.
    df_test <- testing(df_particion)
  5. Construir un árbol de decisión para predecir el riesgo de infarto.

    Utilizar la función decision_tree del paquete parsnip para crear un modelo de árbol de decisión.

    Parámetros:
    • tree_depth: la profundidad máxima del árbol (5 por defecto).
    • cost_complexity: el coste por complejidad del árbol (0.01 por defecto).
    • min_n: el número mínimo de observaciones en un nodo para que se realice una división (1 por defecto).

    Después, utilizar la función set_engine para especificar el motor a utilizar (en este caso, rpart).

    Finalmente, utilizar la función set_mode para especificar que se trata de un modelo de clasificación.

    # Creamos un modelo de árbol de decisión.
    modelo <- decision_tree() |> 
        # Establecemos el motor de rpart.
        set_engine("rpart") |> 
        # Establecemos el modo de clasificación.
        set_mode("classification") 
    
    # Crear un flujo de trabajo con el modelo y los datos de entrenamiento.
    modelo_entrenado <- workflow() |>
        # Añadimos la fórmula del modelo.
        add_formula(Infarto ~ .) |>
        # Añadimos el modelo de árbol de decisión.
        add_model(modelo) |>
        # Ajustamos el modelo a los datos de entrenamiento.
        fit(data = df_entrenamiento)
  6. Dibujar el árbol de decisión construido.

    Utilizar la función extract_fit_engine para extraer el modelo entrenado del flujo de trabajo y luego utilizar la función rpart.plot para dibujar el árbol de decisión.

    library(rpart.plot)
    # Extraemos el modelo ajustado.
    modelo_entrenado |> extract_fit_engine() |> 
        # Dibujamos el árbol de decisión.
        rpart.plot()

  7. Evaluar el modelo de árbol de decisión con el conjunto de test. Calcular la matriz de confusión y también la precisión, sensibilidad y la especificidad.

    Usar la función augment del paquete parsnip para añadir al conjunto de test las probabilidades cada especie de pingüino.

    Parámetros:
    • new_data: el conjunto de datos de test.

    Usar la función conf_mat del paquete yardstick para calcular la matriz de confusión.

    Parámetros:
    • truth: la variable respuesta (en este caso, Especie).
    • estimate: la variable con las clases predichas por el modelo (en este caso, .pred_class).

    Usar la función metric_set del paquete yardstick para crear un conjunto de métricas de evaluación del modelo. En este caso se utilizarán las métricas de precisión (accuracy), sensibilidad (sensitivity) y especificidad (specificity).

    Usar la función metrics del paquete yardstick para calcular las métricas de evaluación del modelo.

    Parámetros:
    • truth: la variable respuesta (en este caso, Especie).
    • estimate: la variable con las clases predichas por el modelo (en este case, .pred_class).
    # Ampliamos el conjunto de test con las predicciones del modelo.
    df_test_aumentado <- modelo_entrenado |> augment(new_data = df_test) 
    # Calculamos la matriz de confusión.
    matriz_confusion <- df_test_aumentado |> conf_mat(truth = Infarto, estimate = .pred_class)
    matriz_confusion$table |> kable()
    No
    No 71 17
    11 85
    # Calculamos la precisión, sensibilidad y especificidad.
    metricas <- metric_set(accuracy, sensitivity, specificity)
    df_test_aumentado |> metricas(truth = Infarto, estimate = .pred_class) |> 
        kable()
    .metric .estimator .estimate
    accuracy binary 0.8478261
    sensitivity binary 0.8658537
    specificity binary 0.8333333
  8. Explorar para qué parámetros del árbol se obtiene el mejor modelo. En particular, estudiar la profundidad máxima del árbol, el coste por complejidad y el mínimo número de casos necesario para dividir un nodo. Calcular la precisión del modelo mediante validación cruzada de 5 pliegues.

    Utilizar la función tune() del paquete hardhat para definir el parámetro a optimizar en el modelo de árbol de decisión. En este caso, se pueden definir los siguientes parámetros:
    • tree_depth: la profundidad máxima del árbol.
    • cost_complexity: el coste por complejidad del árbol.
    • min_n: el número mínimo de casos necesario para dividir un nodo.

    Después, utilizar la función tune_grid del paquete tune para optimizar los parámetros del modelo de árbol de decisión.

    Parámetros:
    • resamples: el conjunto de datos de entrenamiento particionado en pliegues para validación cruzada (en este caso, vfold_cv(df_entrenamiento, v = 5)).
    • grid: un data frame con los valores de los parámetros a probar.
    # Definimos el modelo de árbol de decisión con los parámetros a optimizar.
    modelo <- decision_tree(
        tree_depth = tune(), # Profundidad máxima del árbol.
        cost_complexity = tune(), # Coste por complejidad.
        min_n = tune() # Mínimo número de casos necesario para dividir un nodo.
    ) |> 
        # Establecemos el motor de entrenamiento de rpart.
        set_engine("rpart") |> 
        # Establecemos el modo de clasificación.
        set_mode("classification")
    
    # Creamos un flujo de trabajo con el modelo y los datos de entrenamiento.
    flujo <- workflow() |>
        # Añadimos la fórmula del modelo.
        add_formula(Infarto ~ .) |>
        # Añadimos el modelo de árbol de decisión.
        add_model(modelo)
    
    # Entrenamos el modelo con validación cruzada de 5 pliegues.
    modelos_entrenados <- flujo |> tune_grid(
            resamples = vfold_cv(df_entrenamiento, v = 5),
            grid = expand.grid(
                tree_depth = 3:6, # Profundidad máxima del árbol.
                cost_complexity = seq(0, 0.5, by = 0.02), # Coste por complejidad.
                min_n = 10:15 # Mínimo número de casos necesario para dividir un nodo.
            ),
            metrics = metric_set(accuracy) # Métricas a calcular.
        ) 
  9. Construir el árbol de decisión con los parámetros óptimos obtenidos en el apartado anterior.

    Utilizar la función select_best del paquete tune para seleccionar los mejores parámetros de los modelos entrenados.

    Parámetros:
    • modelos_entrenados: el objeto con los modelos entrenados.
    • metric: la métrica a utilizar para seleccionar los mejores parámetros (en este caso, accuracy).

    Después, utilizar la función finalize_workflow del paquete workflows para finalizar el flujo de trabajo con los mejores parámetros.

    Parámetros:
    • Los mejores parámetros de los modelos entrenados.

    Finalmente, utilizar la función last_fit del paquete workflows para entrenar el modelo con el conjunto de entrenamiento y evaluarlo con el conjunto de test.

    Parámetros:
    • split: el objeto con la partición de los datos (en este caso, df_particion).
    • metrics: las métricas a utilizar para evaluar el modelo.
    # Extraemos los mejores parámetros.
    parametros_optimos <- modelos_entrenados |> select_best(metric = "accuracy") 
    parametros_optimos |> kable()
    cost_complexity tree_depth min_n .config
    0 3 11 Preprocessor1_Model105
    # Entrenamos el modelo con los mejores parámetros.
    modelo_final <- flujo |> finalize_workflow(parametros_optimos) |>
        last_fit(split = df_particion, metrics = metricas) 
    
    # Extraemos las métricas de evaluación del modelo entrenado.
    collect_metrics(modelo_final) |> kable()
    .metric .estimator .estimate .config
    accuracy binary 0.8478261 Preprocessor1_Model1
    sensitivity binary 0.8658537 Preprocessor1_Model1
    specificity binary 0.8333333 Preprocessor1_Model1
  10. Dibujar el árbol de decisión final.

    # Extraemos el modelo ajustado.
    modelo_final |> extract_fit_engine() |> 
        # Dibujamos el árbol de decisión.
        rpart.plot()

  11. Dibujar un diagrama con la importancia de las variables del árbol de decisión.

    Utilizar la función vip del paquete vip para dibujar la importancia de las variables del modelo.

    library(vip)
    # Extraemos el modelo ajustado.
    modelo_final |> extract_fit_engine() |> 
        # Dibujamos la importancia de las variables.
        vip()

  12. Construir bosques aleatorios para predecir el riesgo de infarto, explorando para qué parámetros se obtiene el mejor modelo. En particular, estudiar el número de variables a considerar en cada división y el mínimo número de casos necesario para dividir un nodo. Calcular la precisión y en área bajo la curva ROC del modelo mediante validación cruzada de 5 pliegues.

    Utilizar la función rand_forest del paquete parsnip para crear un modelo de bosque aleatorio.

    Parámetros:
    • trees: el número de árboles en el bosque (1000 por defecto).
    • mtry: el número de variables a considerar en cada división (se puede utilizar tune() para optimizar este parámetro).
    • min_n: el número mínimo de casos necesario para dividir un nodo (se puede utilizar tune() para optimizar este parámetro).
    # Número de procesadores para el entrenamiento en paralelo.
    library(parallel)
    procesadores <- detectCores() - 1 # Usamos todos menos uno para evitar saturar el sistema.
    # Creamos un modelo de bosque aleatorio.
    modelo_bosque <- rand_forest(
        trees = 1000, # Número de árboles en el bosque.
        mtry = tune(), # Número de variables a considerar en cada división.
        min_n = tune() # Mínimo número de casos necesario para dividir un nodo.
    ) |> 
        # Establecemos el motor de ranger.
        set_engine("ranger", num.threads = procesadores, importance = "impurity") |> 
        # Establecemos el modo de clasificación.
        set_mode("classification")
    
    # Creamos un flujo de trabajo con el modelo y los datos de entrenamiento.
    flujo_bosque <- workflow() |>
        # Añadimos la fórmula del modelo.
        add_formula(Infarto ~ .) |>
        # Añadimos el modelo de bosque aleatorio.
        add_model(modelo_bosque)
    
    # Dividimos el conjunto de entrenamiento en entrenamiento y validación estratificando por la variable Infarto.
    df_validacion <- validation_split(df_entrenamiento, prop = 0.8, strata = Infarto) 
    
    # Establecemos una semilla aleatoria para la reproducibilidad.
    set.seed(123)
    # Entrenamos el modelo con validación cruzada de 5 pliegues.
    # Utilizamos la función tune_grid para optimizar los parámetros mtry y min_n.
    modelos_entrenados <- flujo_bosque |> tune_grid(
        # Utilizamos validación cruzada de 5 pliegues.
        resamples = vfold_cv(df_entrenamiento, v = 5), 
        # Indicamos que pruebe con 25 valores distintos de mtry y min_n.
        grid = 25,
        # Guardamos las predicciones para cada pliegue.
        control = control_grid(save_pred = TRUE), 
        # Definimos como métricas la precisión y el área bajo la curva ROC.
        metrics = metric_set(accuracy, roc_auc)
    )
    
    # Visualizamos los resultados de la validación cruzada.
    autoplot(modelos_entrenados) 

    # Extraemos los parámetros del mejor modelo entrenado según el área bajo la curva ROC.
    parametros_optimos <- modelos_entrenados |> select_best(metric = "roc_auc")
  13. Construir el bosque aleatorio con los parámetros óptimos obtenidos en el apartado anterior y evaluarlo con el conjunto de test. Calcular la precisión, el área bajo la curva ROC y dibujar la curva ROC del modelo.

    modelo_final <- flujo_bosque |> 
        # Seleccionamos el mejor modelo entrenado según el área bajo la curva ROC.
        finalize_workflow(select_best(modelos_entrenados, metric = "roc_auc")) |> 
        # Entrenamos el modelo con el conjunto de entrenamiento y lo evaluamos con el conjunto de test usando las métricas de precisión y área bajo la curva ROC.
        last_fit(df_particion, metrics = metric_set(accuracy, roc_auc))
    
    # Extraemos las métricas de evaluación del modelo entrenado.
    collect_metrics(modelo_final) |> kable()
    .metric .estimator .estimate .config
    accuracy binary 0.8967391 Preprocessor1_Model1
    roc_auc binary 0.9506217 Preprocessor1_Model1
    # Extraemos las predicciones del modelo.
    modelo_final |> collect_predictions() |> 
        # Dibujamos la curva ROC.
        roc_curve(Infarto, .pred_No) |> 
        autoplot() + 
        labs(title = "Curva ROC del modelo de bosque aleatorio")

  14. Dibujar un diagrama con la importancia de las variables del bosque aleatorio.

    # Extraemos el modelo ajustado del flujo de trabajo.
    modelo_final |> extract_fit_engine() |> 
        # Dibujamos la importancia de las variables.
        vip()

6.2 Ejercicios Propuestos

Ejercicio 6.2 El fichero vinos.csv contiene información sobre las características de vinos blancos y tintos portugueses de la denominación “Vinho Verde”. Las variables que contiene son las siguientes:

Variable Descripción Tipo (unidades)
tipo Tipo de vino Factor (blanco, tinto)
meses.barrica Meses de envejecimiento en barrica Numérica(meses)
acided.fija Cantidad de ácidotartárico Numérica(g/dm3)
acided.volatil Cantidad de ácido acético Numérica(g/dm3)
acido.citrico Cantidad de ácidocítrico Numérica(g/dm3)
azucar.residual Cantidad de azúcar remanente después de la fermentación Numérica(g/dm3)
cloruro.sodico Cantidad de clorurosódico Numérica(g/dm3)
dioxido.azufre.libre Cantidad de dióxido de azufre en forma libre Numérica(mg/dm3)
dioxido.azufre.total Cantidad de dióxido de azufre total en forma libre o ligada Numérica(mg/dm3)
densidad Densidad Numérica(g/cm3)
ph pH Numérica(0-14)
sulfatos Cantidad de sulfato de potasio Numérica(g/dm3)
alcohol Porcentaje de contenido de alcohol Numérica(0-100)
calidad Calificación otorgada por un panel de expertos Numérica(0-10)
  1. Crear un dataframe con los datos del archivo vinos.csv.

  2. Realizar un análisis exploratorio de los datos.

  3. Dividir el conjunto de datos en dos subconjuntos, uno de entrenamiento y otro de test. Utilizar el 80% de los datos para entrenamiento y el 20% restante para test.

  4. Construir un árbol de decisión para predecir la calidad del vino. Explorar para qué parámetros del árbol se obtiene el mejor modelo evaluando los modelos con validación cruzada de 5 pliegues.

  5. Evaluar el mejor modelo de árbol de decisión con el conjunto de test. Calcular la matriz de confusión y también la precisión, sensibilidad y la especificidad.

  6. Dibujar el árbol de decisión construido.

  7. Dibujar un diagrama con la importancia de las variables del árbol de decisión.

  8. Construir bosques aleatorios para predecir la calidad del vino, explorando para qué parámetros se obtiene el mejor modelo evaluando los modelos con validación cruzada de 5 pliegues.

  9. Construir el bosque aleatorio con los parámetros óptimos obtenidos en el apartado anterior y evaluarlo con el conjunto de test. Calcular la precisión, el área bajo la curva ROC y dibujar la curva ROC del modelo.

  10. Dibujar un diagrama con la importancia de las variables del bosque aleatorio.