En este post mostraré algunos trucos que he ido aprendiendo al utilizar Random Forests (RF), una de mis técnicas de clasificación favoritas, para mejorar el poder de clasificación que poseen. Este método, desarrollado por Leo Breiman en el año 2001 tiene sus orígenes en esta publicación en Machine Learning y se ha convertido, especialmente en los últimos cinco o seis años, en una de las preferidas por científicos y practitioners. La técnica es tremendamente poderosa, es muy fácil de usar (en comparación a las redes neuronales por ejemplo), y entrega bastante información sobre los resultados al usuario.

La lógica de los RF es generar muchos árboles de decisión sobre los datos de entrada, utilizando una cantidad reducida de las variables que se encuentran disponibles. La técnica explora así diferentes subsegmentos del espacio de entrada, y aprende patrones muy complejos aprovechando la diversidad de los clasificadores que entrena. Una prueba de su éxito se ha visto en las competencias de Kaggle: es una de las dos técnicas más importantes que identifican los administradores del sitio.

En este ejemplo entrenaré un RF para un dataset del repositorio de la UCI, y luego mostraré algunos trucos: cómo acelerar el entrenamiento utilizando programación paralela para aprovechar mejor los recursos disponibles y cómo graficar una curva ROC utilizando

ggplot2

. En un post futuro mostraré cómo balancear las muestras para solucionar los problemas de desbalance y finalmente cómo graficar la importancia de las variables y analizar estos resultados.

Entrenamiento Secuencial

Primero importemos los datos. Para este ejemplo usaré la base de datos de Bank Marketing, de Moro et. al. Esta base de datos posee 41.188 instancias, 20 variables predictoras, y tiene como objetivo predecir si el cliente toma o no un depósito a plazo. Usaré R para descargar directamente el archivo, descomprimirlo, y cargar los datos. Al final agrego una variable adicional para los test (muestra holdout).

# Descargar el archivo. Se debe usar "method=curl" si la página usa https.
url <- "https://archive.ics.uci.edu/ml/machine-learning-databases/00222/bank-additional.zip";
download.file(url,"bank-additional.zip", mode="wb",method="curl");
# Descomprimir el archivo y cargar la tabla.
unzip("bank-additional.zip");
bank.data <- read.table("bank-additional-full.csv", sep=";", header = TRUE);
bank.data$if.test <- rbinom(nrow(bank.data), 1, 0.7) # Agrega variable para test

Con esto el archivo está cargado en la variable

bank.data

y ya se puede utilizar. Entrenemos un primer Random Forest y veamos sus resultados. Para entrenar los modelos está el paquete

randomForest

de Breiman mismo, y además utilizaré los paquetes

verification

,

rbenchmark

,

e1071

y

caret

para analizar los resultados. Carguemos los paquetes o instalémoslos si es necesario.

# library(..., logical.return = TRUE) devuelve falso si el paquete no existe.
if(!library(randomForest, logical.return = TRUE)) {
  install.packages('randomForest');
}
if(!library(verification, logical.return = TRUE)) {
  install.packages('verification');
}
if(!library(caret, logical.return = TRUE)) {
  install.packages('caret');
}
if(!library(e1071, logical.return = TRUE)) {
  install.packages('e1071');
  library(e1071)
}
if(!library(rbenchmark, logical.return = TRUE)) {
  install.packages('rbenchmark');
  library(rbenchmark)
}

Ahora podemos correr un primer Random Forest.

# Crea fórmula con todas las variables. Con los paste evito escribir de más.
vars.bank <- colnames(bank.data)
formula.rf <- as.formula(paste0("y ~", paste(vars.bank[1:20], collapse = "+")))

# Función para facilitar futuras ejecuciones.
rfNaive <- function(x){randomForest(formula.rf, data = x, 
                                     subset = (if.test == 0), ntree = 500,
                                     importance = TRUE)
}
# Entrena sin ninguna precaución. Corre usando 650MB en RAM y en
# 36 segundos en un procesador core i7-3770k.
rf.naive <- rfNaive(bank.data)

# Calcula AUC y Crea matriz  de confusión con corte en 0.5.
prob.rfNaive <- predict(rf.naive, newdata = bank.data[bank.data$if.test == 1 , ],
                        type = "prob")[, 2]
roc.area(as.numeric(bank.data$y[bank.data$if.test == 1] == "yes"), prob.rfNaive)
confusionMatrix(round(prob.rfNaive),  as.numeric(bank.data$y[bank.data$if.test == 1] == "yes"))
# AUC = 0.9426409
# Matriz de confusión:
#               Reference
# Prediction     0     1
#         0   24711  1546
#         1   939  1703

Entrenando en Paralelo

Los Random Forest son altamente paralelizables pues cada árbol puede entrenarse por si solo, y al final del entrenamiento basta con juntar cada uno de los árboles que se entrenaron. El paquete

randomForest

toma ventaja de eso y entrega directamente una manera de combinar resultados cuando se entrenan bosques en paralelo.

Para paralelizar en R es necesario registrar un backend que es el que se encarga de comunicarse con los procesadores, y además usar un paquete que permita paralelizar el código. Hay distintos paquetes con distintos niveles de profundidad en cuánto a control (y cuanta dificultad) entregan al usuario. Lo que me ha resultado más fácil es:

  • Usar como backend ya sea el paquete doMC (Linux) o el paquete doParallel(Windows). Lamentablemente no existe (que yo sepa) un paquete que funcione en ambos sistemas.
  • Usar el paquete foreach para realizar la paralelización.

Las opciones de estos paquetes son amplias, y escapan el alcance de este post, así que me enfocaré en lo necesario para correr randomForests. Para todos los detalles de paralelizar R se puede comenzar por la viñeta de foreach.

¿Qué es paralelizar? La gran mayoría de los computadores modernos traen más de un procesador (núcleo) incorporado para correr procesos. La ventaja de tener más de un procesador radica en que el usuario usa múltiples programas y servicios en su equipo en cualquier momento, y el tener más de un núcleo permite poder realizar varias actividades de forma simultánea. Nosotros podemos tomar ventaja de esto para generar códigos más eficientes, que utilicen todo el poder computacional que tenemos. Para paralelizar un Random Forest la estrategia a seguir será simplemente decirle al programa que entrene una cierta cantidad de árboles en cada núcleo, y después combine los resultados en un único bosque aleatorio.

La computación paralela tiene una propiedad que es importante tener presente: dependiendo de cuán eficiente es la implementación del código, puede multiplicar la cantidad de memoria utilizada por la cantidad de procesadores que se utilizan. Así, si una aplicación utiliza 500MB en RAM en un núcleo, perfectamente puede utilizar 2GB si se utilizan cuatro, por ejemplo. Por lo mismo, mucho cuidado al correr este ejemplo, ajusten los valores de los parámetros acorde a la memoria RAM que tengan disponibles.

Para paralelizar nuestro Random Forest, lo primero es determinar cuántos núcleos poseemos y cuántos árboles queremos entrenar. En este ejemplo estamos entrenando 500 árboles, y en mi PC tengo ocho núcleos y suficiente RAM, así que los usaré todos. El número de procesadores se puede obtener googleando el tipo de procesador que tienen, o en Windows con el backend parallel pueden correr

detectCores()

en R, o en Linux el backend doMC utiliza la mitad de los núcleos disponibles de forma predeterminada.

Con eso listo ahora podemos paralelizar. Le diremos a R que entrene sub-bosques, y después los combine. El código es el siguiente:

cores <- 4 # Depende de cada computador.
ntrees <- 500 # Decisión del usuario.
library(doMC)
registerDoMC(cores) # Registra el backend. Solo en Linux.
# En Windows:
# library(doParallel)
# registerDoParallel(cores = cores)

rfParallel <- function(x){
  foreach(ntree.iter = rep(ceiling(ntrees / cores), cores), 
          .combine = combine, .packages = "randomForest") %dopar% {
            randomForest(formula.rf, data = x, ntree = ntree.iter,
                         do.trace = FALSE, subset = (if.test == 0),
                         keep.forest = TRUE, importance = TRUE)
          }
}

rf.parallel <- rfParallel(bank.data)

# Calcular medidas efectividad
prob.rfParallel <- predict(rf.parallel, newdata = bank.data[bank.data$if.test == 1 , ],
                        type = "prob")[, 2]
roc.area(as.numeric(bank.data$y[bank.data$if.test == 1] == "yes"), prob.rfParallel)
confusionMatrix(round(prob.rfParallel),  
                as.numeric(bank.data$y[bank.data$if.test == 1] == "yes"))
# AUC = 0.9439
# Matriz de confusión:
#               Reference
# Prediction     0     1
#       0      24665  1584
#       1        905  1673
# Accuracy: 91.37%
# Balanced Accuracy: 73.91%

Como podemos ver, los resultados son equivalentes entre los dos modelos. Existe una pequeña caída en la capacidad discriminante, pero esto es sólo por razones aleatorias.

Los parámetros de la función

foreach

son los importantes:

  •  ntree.iter = rep(ceiling(ntrees / cores), cores)

    : Indica que cada iteración de foreach entrene un total de 500 divido por la cantidad de núcleos que disponen.

  • .combine = combine

    : Indica a

    foreach

    que utilice la función «combine» del paquete

    randomForest

    para combinar los resultados. Esta función permite unir bosques aleatorios en bosques más grandes.

  • .packages='randomForest'

    : Indica a

    foreach

    que, en cada núcleo donde correrá la función, cargue el paquete

    randomForest

    .

  • %dopar%

    : Indica a

    foreach

    que corra en paralelo utilizando el backend que se encuentre registrado (

    doMC

    en este caso).

Ahora comparemos el tiempo de corrida. Repetiré tres veces el entrenamiento utilizando el excelente paquete

rBenchmark

.

benchmark(rfNaive(bank.data), rfParallel(bank.data), 
          order = "elapsed", replications = 3,
          columns = c("test", "replications", "elapsed", "relative",
                      "user.self"))

#         test           replications elapsed relative user.self
#2 rfParallel(bank.data)       3       77.760   1.00     2.204
#1    rfNaive(bank.data)       3      148.557   1.91   138.380

Este ejemplo lo corrí en mi laptop con dos núcleos reales y cuatro virtuales. La programación en paralelo corre casi dos veces más rápido que el código secuencial. Esto es en general lo que ocurre: existe una ganacia que es casi lineal con el número de núcleos físicos que se disponen.

 

Consejo 2: Graficar las curvas ROC con ggplot2

Para finalizar este post mostraré algo simple para quienes reportan los resultados de modelos. Las curvas ROC de R en general se ven bastante mal, incluso en el mismo paquete

verification

indican que existen mejores alternativas para graficar.

El paquete más potente para gráficos en R corresponde a ggplot2, pero utilizarlos es bastante complejo. El paquete utiliza el concepto de estéticas, o variables que se asignan a distintas partes del gráfico. Toma un poco de tiempo acostumbrarse a esto, pero una vez que se logra los resultados son muy buenos.

Para graficar una curva ROC, es necesario primero que nada de disponer un data.frame tal que muestre los puntos de la curva ROC y a quién se refiere. Luego creamos el gráfico, asignamos la variable que identifica al modelo tanto a la estética de grupos como a la de colores, y luego le damos los valores correspondientes. El código es el siguiente:

# Crea estructura con curva ROC
roc.rfParallel <- roc.plot(as.numeric(bank.data$y[bank.data$if.test == 1] == "yes"), 
                           prob.rfParallel)
roc.rfParallel <- as.data.frame(roc.rfParallel$plot.data)
roc.rfNaive <- roc.plot(as.numeric(bank.data$y[bank.data$if.test == 1] == "yes"), 
                           prob.rfNaive)
roc.rfNaive <- as.data.frame(roc.rfNaive$plot.data)

temp <- data.frame(fpr = roc.rfParallel$V3, var = 'En Paralelo', value = roc.rfParallel$V2)
temp <- rbind(temp, data.frame(fpr = roc.rfNaive$V3, var = 'Secuencial', value = roc.rfNaive$V2))

# Leyenda del gráfico
legend.roc <- rep("",2)
legend.roc[1] <- paste0("En Paralelo: AUC = ", round(with(bank.data, roc.area(as.numeric(y[if.test == 1] == "yes"),
                                                                              prob.rfParallel))$A,3))
legend.roc[2] <- paste0("Secuencial: AUC = ", round(with(bank.data, roc.area(as.numeric(y[if.test == 1] == "yes"),
                                                                                 prob.rfNaive))$A,3))

breaks <- c("En Paralelo", "Secuencial")

# Gráfico
library(ggplot2)
library(grid)

p <- (ggplot(temp, aes(fpr, value, group = var, color = var)) 
      + geom_line(size = 0.5, aes(linetype = var))+ theme_bw()
      + scale_linetype_manual(name = 'leyenda', values=c("dotted", "solid"), 
                              labels = legend.roc, breaks = breaks)
      + scale_color_discrete(name = 'leyenda', breaks = breaks, labels = legend.roc)
      + scale_x_continuous("False Positive Rate (1-Specificity)")
      + scale_y_continuous("True Positive Rate (Sensitivity)")
      + coord_fixed()
      + theme(legend.justification=c(1,0), legend.position=c(1,0),
              legend.title = element_blank(), 
              legend.text = element_text(size = 14),
              legend.key.width = unit(3, "cm"),
              legend.key = element_blank()
      )
)
plot(p)

El resultado es:

 

Curva ROC Random Forests

Curva ROC Random Forests

Como se observa, ambos modelos son idénticos. Esto puede cambiar en una comparación entre distintos tipos de modelos.

Este post mostró tanto como entrenar los bosques aleatorios en paralelo y como generar curvas ROC con

ggplot2

. Cualquier comentario, por twitter o directamente acá en el blog. ¡Gracias por leer!

Actualización: Una versión anterior de este post tenía algunos paquetes faltantes en el código. ¡Gracias Pablo!