Convertido a RMarkdown por Hong Ooi
Adaptado al español por Pabloe Cancino Marentes
Una de las características más útiles de R es su intérprete interactivo. Esto hace que sea muy fácil aprender y experimentar con R. Le permite usar R como una calculadora para realizar operaciones aritméticas, mostrar conjuntos de datos, generar gráficos y crear modelos.
En poco tiempo, los nuevos usuarios de R encontrarán la necesidad de realizar alguna operación repetidamente. Tal vez quieran ejecutar una simulación repetidamente para encontrar la distribución de los resultados. Tal vez necesiten ejecutar una función con una variedad de argumentos diferentes. O tal vez necesiten crear un modelo para muchos conjuntos de datos diferentes.
Las ejecuciones repetidas se pueden realizar manualmente, pero se vuelve bastante tedioso ejecutar operaciones repetidas, incluso con el uso de la edición de línea de comandos. Afortunadamente, R es mucho más que una calculadora interactiva. Tiene su propio lenguaje incorporado que está destinado a automatizar tareas tediosas, como ejecutar repetidamente cálculos R.
El programa R incluye varias construcciones de bucle que resuelven este problema. El bucle for es una de las construcciones de bucle más comunes, pero las declaraciones repeaty while también son muy útiles. Además, existe la familia de funciones “aplicar”, que incluye apply, lapply, sapply, eapply, mapply, rapply y otras.
El paquete foreach proporciona una nueva construcción de bucle para ejecutar código R repetidamente. Con la desconcertante variedad de construcciones de bucles existentes, puede dudar de que se necesite otra construcción. La razón principal para usar el paquete foreach es que admite la ejecución en paralelo , es decir, puede ejecutar esas operaciones repetidas en múltiples procesadores/núcleos en su computadora, o en múltiples nodos de un clúster. Si cada operación lleva más de un minuto y desea ejecutarla cientos de veces, el tiempo de ejecución general puede llevar horas. Pero usando foreach, esa operación se puede ejecutar en paralelo en cientos de procesadores en un clúster, lo que reduce el tiempo de ejecución a minutos.
Pero la ejecución paralela no es la única razón para usar el paquete foreach. Hay otras razones por las que puede elegir usarlo para ejecutar operaciones de ejecución rápida, como veremos más adelante en el documento.
Echemos un vistazo a un ejemplo simple de uso del paquete foreach. Primeramente, tendría que tener el paqueteforeach instalado.
install.packages("foreach")
Una vez instalado, primero deberá cargarlo antes de utilizarlo. Tenga en cuenta que también se cargarán todos los paquetes que dependen de foreach.:
library(foreach)
## Warning: package 'foreach' was built under R version 4.1.3
Como ejemplo, ahora puede usar foreach para ejecutar la función sqrt repetidamente, pasándole los valores del 1 al 3 y devolviendo los resultados en una lista, llamada x. (Por supuesto, sqrt es una función vectorizada, por lo que en realidad nunca harías esto. Pero más adelante, veremos cómo aprovechar las funciones vectorizadas con foreach).
x <- foreach(i=1:3) %do% sqrt(i)
x
## [[1]]
## [1] 1
##
## [[2]]
## [1] 1.414214
##
## [[3]]
## [1] 1.732051
La función foreach tiene un aspecto un poco extraño, porque se parece vagamente a un bucle for, pero se implementa mediante un operador binario, llamado %do%. Además, a diferencia de un bucle for, devuelve un valor. Para procesosuméricos, esto es bastante importante. El propósito de esta instrucción es calcular una lista de resultados. Generalmente, foreach con %do% se usa para ejecutar una expresión R repetidamente y devolver los resultados en alguna estructura de datos u objeto, que es una lista por defecto.
Notará en el ejemplo anterior que usamos una variable i como argumento de la función sqrt. Especificamos los valores de la variable i usando un argumento con nombre para la función foreach. Podríamos haber llamado a esa variable como quisiéramos, por ejemplo a, o b. También podríamos especificar otras variables para usar en la expresión en R, como en el siguiente ejemplo:
x <- foreach(a=1:3, b=rep(10, 3)) %do% (a + b)
x
## [[1]]
## [1] 11
##
## [[2]]
## [1] 12
##
## [[3]]
## [1] 13
Tenga en cuenta que aquí se necesitan paréntesis. También podemos usar llaves:
x <- foreach(a=1:3, b=rep(10, 3)) %do% {
a + b
}
x
## [[1]]
## [1] 11
##
## [[2]]
## [1] 12
##
## [[3]]
## [1] 13
Se denominan a a y $b$, “variables de iteración” , ya que estas son las variables que están cambiando durante las múltiples ejecuciones. Tenga en cuenta que estamos iterando sobre ellas en paralelo, es decir, ambas están cambiando al mismo tiempo. En este caso, se especifica el mismo número de valores para ambas variables de iteración, pero no tiene por qué ser así necesariamente. Si solo proporcionamos dos valores para b, el resultado sería una lista de longitud dos, incluso si especificamos mil valores para a:
x <- foreach(a=1:1000, b=rep(10, 2)) %do% {
a + b
}
x
## [[1]]
## [1] 11
##
## [[2]]
## [1] 12
Tenga en cuenta que puede poner varias declaraciones entre llaves y puede usar declaraciones de asignación para guardar valores intermedios de cálculos. Sin embargo, si usa una asignación como una forma de comunicación entre las diferentes ejecuciones de su ciclo, entonces su código no funcionará correctamente en paralelo, lo cual discutiremos más adelante.
Hasta ahora, todos nuestros ejemplos han devuelto una lista de resultados. Este es un buen valor predeterminado, ya que una lista puede contener cualquier objeto R. Pero a veces nos gustaría que los resultados se devuelvan en un vector numérico, por ejemplo. Esto se puede hacer usando la opción .combine para foreach:
x <- foreach(i=1:3, .combine='c') %do% exp(i)
x
## [1] 2.718282 7.389056 20.085537
El resultado se devuelve como un vector numérico, porque la función c de R estándar se usa para concatenar todos los resultados. Dado que la expfunción devuelve valores numéricos, concatenarlos con la función c dará como resultado un vector numérico de longitud tres.
¿Qué pasa si la expresión R devuelve un vector y queremos combinar esos vectores en una matriz? Una forma de hacerlo es con la función cbind:
x <- foreach(i=1:4, .combine='cbind') %do% rnorm(4)
x
## result.1 result.2 result.3 result.4
## [1,] -0.4575275 0.5601387 -1.17954585 1.4412710
## [2,] 0.4160417 -0.1576982 0.05160534 -0.5393398
## [3,] -0.7658385 -0.1865816 -0.79334361 0.5805905
## [4,] -1.4595529 0.5721529 0.21383724 0.7447609
Esto genera cuatro vectores de cuatro números aleatorios y los combina por columna para producir una matriz de 4 por 4.
También podemos usar las funciones "+"o "*"para combinar nuestros resultados:
x <- foreach(i=1:4, .combine='+') %do% rnorm(4)
x
## [1] 0.5173218 2.9457439 3.7498110 3.3072016
También puede especificar una función escrita por el usuario para combinar los resultados. Aquí hay un ejemplo que tira los resultados:
cfun <- function(a, b) NULL
x <- foreach(i=1:4, .combine='cfun') %do% rnorm(4)
x
## NULL
Tenga en cuenta que esta cfunfunción toma dos argumentos. La función foreach sabe que las funciones c, cbindy rbindtoman muchos argumentos, y los llamará con hasta 100 argumentos (de forma predeterminada) para mejorar el rendimiento. Pero si se especifica cualquier otra función (como "+"), asume que solo toma dos argumentos. Si la función permite muchos argumentos, puede especificarlo usando el argumento .multicombine:
cfun <- function(...) NULL
x <- foreach(i=1:4, .combine='cfun', .multicombine=TRUE) %do% rnorm(4)
x
## NULL
Si desea que se llame a la función de combinación con no más de 10 argumentos, puede especificarlo con la opción .maxcombine:
cfun <- function(...) NULL
x <- foreach(i=1:4, .combine='cfun', .multicombine=TRUE, .maxcombine=10) %do% rnorm(4)
x
## NULL
La opción .inorder se utiliza para especificar si el orden en que se combinan los argumentos es importante. El valor predeterminado es TRUE, pero si la función de combinación es "+", puede especificar .inorderque sea FALSE. En realidad, esta opción es importante solo cuando se ejecuta la expresión R en paralelo, ya que los resultados siempre se calculan en orden cuando se ejecutan secuencialmente. Sin embargo, esto no es necesariamente cierto cuando se ejecuta en paralelo. De hecho, si las expresiones tardan mucho tiempo en ejecutarse, los resultados podrían devolverse en cualquier orden. Aquí hay un ejemplo artificial que ejecuta las tareas en paralelo para demostrar la diferencia. El ejemplo usa la función Sys.sleep para hacer que las tareas anteriores tarden más en ejecutarse:
foreach(i=4:1, .combine='c') %dopar% {
Sys.sleep(3 * i)
i
}
## Warning: executing %dopar% sequentially: no parallel backend registered
## [1] 4 3 2 1
foreach(i=4:1, .combine='c', .inorder=FALSE) %dopar% {
Sys.sleep(3 * i)
i
}
## [1] 4 3 2 1
Se garantiza que los resultados del primero de estos dos ejemplos serán el vector c(4, 3, 2, 1). El segundo ejemplo devolverá los mismos valores, pero probablemente estarán en un orden diferente.
Los valores de las variables de iteración no tienen que especificarse solo con vectores o listas. Se pueden especificar con un iterador , muchos de los cuales vienen con el paquete iterators. Un iterador es una fuente abstracta de datos. Un vector no es en sí mismo un iterador, pero la función foreach crea automáticamente un iterador a partir de un vector, una lista, una matriz o un marco de datos, por ejemplo. También puede crear un iterador a partir de un archivo o una consulta de base de datos, que son fuentes naturales de datos. El paquete iterators proporciona una función llamada irnorm que puede devolver un número específico de números aleatorios cada vez que se llama. Por ejemplo:
library(iterators)
x <- foreach(a=irnorm(4, count=4), .combine='cbind') %do% a
x
## result.1 result.2 result.3 result.4
## [1,] -0.7174910 -0.8449982 -0.05183148 0.2990485
## [2,] 0.7500536 0.3664019 2.81882836 1.1102661
## [3,] 2.1153107 0.3121930 -0.67610253 0.3379510
## [4,] -0.3674959 1.7745025 0.57372538 -1.3372453
Esto se vuelve útil cuando se trata de grandes cantidades de datos. Los iteradores permiten que los datos se generen sobre la marcha, según los necesiten sus operaciones, en lugar de requerir que todos los datos se generen al principio.
Por ejemplo, digamos que queremos sumar mil vectores aleatorios:
set.seed(123)
x <- foreach(a=irnorm(4, count=1000), .combine='+') %do% a
x
## [1] 9.097676 -13.106472 14.076261 19.252750
Esto usa muy poca memoria, ya que es equivalente al siguiente ciclo while:
set.seed(123)
x <- numeric(4)
i <- 0
while (i < 1000) {
x <- x + rnorm(4)
i <- i + 1
}
x
## [1] 9.097676 -13.106472 14.076261 19.252750
Esto podría haberse hecho usando la función icount, que genera los valores de uno a 1000:
set.seed(123)
x <- foreach(icount(1000), .combine='+') %do% rnorm(4)
x
## [1] 9.097676 -13.106472 14.076261 19.252750
pero a veces es preferible generar los datos reales con el iterador (como veremos más adelante cuando ejecutemos en paralelo).
Además de presentar la función icount del paquete iterators, el último ejemplo también usó un argumento sin nombre para la función foreach. Esto puede ser útil cuando no pretendemos generar valores de variables, sino solo controlar la cantidad de veces que se ejecuta la expresión R.
Hay mucho más que podría decir sobre los iteradores, pero por ahora, pasemos a la ejecución en paralelo.
Aunque el paquete foreachpuede ser una construcción útil por derecho propio, el objetivo real del paquete foreach es hacer computación paralela. Para hacer que cualquiera de los ejemplos anteriores se ejecute en paralelo, todo lo que tiene que hacer es reemplazar %do% con %dopar%. Pero para el tipo de operaciones de ejecución rápida que hemos estado haciendo, no tendría mucho sentido ejecutarlas en paralelo. Ejecutar muchas tareas pequeñas en paralelo generalmente llevará más tiempo ejecutarlas que ejecutarlas secuencialmente, y si ya se ejecuta rápido, no hay motivación para que se ejecute más rápido de todos modos. Pero si la operación que estamos ejecutando en paralelo tarda un minuto o más, empieza a haber alguna motivación.
Tomemos un bosque aleatorio como ejemplo de una operación que puede tardar un tiempo en ejecutarse. Digamos que nuestras entradas son la matriz x y el factor y:
x <- matrix(runif(500), 100)
y <- gl(2, 50)
Ya hemos cargado el paquete foreach, pero también necesitaremos cargar el paquete randomForest:
## install.packages("randomForest")
library(randomForest)
Si queremos crear un modelo de bosque aleatorio con 1000 árboles, y nuestra computadora tiene cuatro núcleos, podemos dividir el problema en cuatro partes ejecutando la función randomForest cuatro veces, con el argumento ntree establecido en 250. Por supuesto, tenemos que combinar los objetos randomForest resultantes, pero el paquete randomForest viene con una función llamada combineque hace precisamente eso.
Hagámoslo, pero primero, haremos el trabajo secuencialmente:
rf <- foreach(ntree=rep(250, 4), .combine=combine) %do%
randomForest(x, y, ntree=ntree)
rf
##
## Call:
## randomForest(x = x, y = y, ntree = ntree)
## Type of random forest: classification
## Number of trees: 1000
## No. of variables tried at each split: 2
Para ejecutar esto en paralelo, necesitamos cambiar \%do\%, pero también necesitamos usar otra foreachopción llamada .packagespara decirle al foreachpaquete que la expresión R necesita tener el randomForestpaquete cargado para ejecutarse con éxito. Aquí está la versión paralela:
rf <- foreach(ntree=rep(250, 4), .combine=combine, .packages='randomForest') %dopar%
randomForest(x, y, ntree=ntree)
rf
##
## Call:
## randomForest(x = x, y = y, ntree = ntree)
## Type of random forest: classification
## Number of trees: 1000
## No. of variables tried at each split: 2
Si ha realizado cómputo paralelo, particularmente en un clúster, puede preguntarse por qué no tuve que hacer nada especial para manejar x y y. La razón es que la función dopar notó que esas variables estaban referenciadas y que estaban definidas en el entorno actual. En ese caso %dopar%, los exportará automáticamente a los trabajadores de ejecución en paralelo una vez y los usará para todas las evaluaciones de expresión para esa ejecuciónforeach. Eso también es cierto para las funciones que se definen en el entorno actual, pero en este caso, la función se define en un paquete, por lo que tuvimos que especificar el paquete para cargar con la opción .packages en su lugar.
Ahora echemos un vistazo a cómo hacer una versión paralela de la applyfunción R estándar. La applyfunción está escrita en R, y aunque solo tiene unas 100 líneas de código, es un poco difícil de entender en una primera lectura. Sin embargo, todo realmente se reduce a dos forbucles, el un poco más complicado de los cuales parece:
applyKernel <- function(newX, FUN, d2, d.call, dn.call=NULL, ...) {
ans <- vector("list", d2)
for(i in 1:d2) {
tmp <- FUN(array(newX[,i], d.call, dn.call), ...)
if(!is.null(tmp)) ans[[i]] <- tmp
}
ans
}
applyKernel(matrix(1:16, 4), mean, 4, 4)
## [[1]]
## [1] 2.5
##
## [[2]]
## [1] 6.5
##
## [[3]]
## [1] 10.5
##
## [[4]]
## [1] 14.5
He convertido esto en una función, porque de lo contrario, R se quejará de que estoy usando ...en un contexto no válido.
Esto podría ejecutarse usando foreachlo siguiente:
applyKernel <- function(newX, FUN, d2, d.call, dn.call=NULL, ...) {
foreach(i=1:d2) %dopar%
FUN(array(newX[,i], d.call, dn.call), ...)
}
applyKernel(matrix(1:16, 4), mean, 4, 4)
## [[1]]
## [1] 2.5
##
## [[2]]
## [1] 6.5
##
## [[3]]
## [1] 10.5
##
## [[4]]
## [1] 14.5
Pero este enfoque hará que la newXmatriz completa se envíe a cada uno de los trabajadores de ejecución en paralelo. Dado que cada tarea necesita solo una columna de la matriz, nos gustaría evitar esta comunicación de datos adicional.
Una forma de resolver este problema es usar un iterador que itera sobre la matriz por columna:
applyKernel <- function(newX, FUN, d2, d.call, dn.call=NULL, ...) {
foreach(x=iter(newX, by='col')) %dopar%
FUN(array(x, d.call, dn.call), ...)
}
applyKernel(matrix(1:16, 4), mean, 4, 4)
## [[1]]
## [1] 2.5
##
## [[2]]
## [1] 6.5
##
## [[3]]
## [1] 10.5
##
## [[4]]
## [1] 14.5
Ahora solo estamos enviando una columna determinada de la matriz a un trabajador de ejecución en paralelo. Pero sería aún más eficiente si enviáramos la matriz en trozos más grandes. Para hacer eso, usamos una función llamada iblkcolque devuelve un iterador que devolverá varias columnas de la matriz original. Eso significa que la expresión R deberá ejecutar la función del usuario una vez para cada columna en su submatriz.
iblkcol <- function(a, chunks) {
n <- ncol(a)
i <- 1
nextElem <- function() {
if (chunks <= 0 || n <= 0) stop('StopIteration')
m <- ceiling(n / chunks)
r <- seq(i, length=m)
i <<- i + m
n <<- n - m
chunks <<- chunks - 1
a[,r, drop=FALSE]
}
structure(list(nextElem=nextElem), class=c('iblkcol', 'iter'))
}
nextElem.iblkcol <- function(obj) obj$nextElem()
applyKernel <- function(newX, FUN, d2, d.call, dn.call=NULL, ...) {
foreach(x=iblkcol(newX, 3), .combine='c', .packages='foreach') %dopar% {
foreach(i=1:ncol(x)) %do% FUN(array(x[,i], d.call, dn.call), ...)
}
}
applyKernel(matrix(1:16, 4), mean, 4, 4)
## [[1]]
## [1] 2.5
##
## [[2]]
## [1] 6.5
##
## [[3]]
## [1] 10.5
##
## [[4]]
## [1] 14.5
Tenga en cuenta el uso del %do%interior de %dopar%para llamar a la función en las columnas de la submatriz x. Ahora que estamos usando %do%nuevamente, tiene sentido que el iterador sea un índice en la matriz x, ya %do%que no necesita copiar de xla forma en que %dopar%lo hace.
Si está familiarizado con el lenguaje de programación Python, es posible que se le haya ocurrido que el foreachpaquete proporciona algo que no es muy diferente de las listas de comprensión de Python . De hecho, el foreachpaquete también incluye una función llamada whenque puede evitar que ocurran algunas de las evaluaciones, muy similar a la cláusula “si” en las listas de comprensión de Python. Por ejemplo, podría filtrar los valores negativos de un iterador whende la siguiente manera:
x <- foreach(a=irnorm(1, count=10), .combine='c') %:% when(a >= 0) %do% sqrt(a)
x
## [1] 0.4055020 1.0835713 0.8704032 0.3653185 1.4166866 0.8115083
No diré mucho sobre este tema, pero no puedo evitar mostrar cómo se puede usar foreachwith whenpara escribir una función de clasificación rápida simple, al estilo clásico de Haskell:
qsort <- function(x) {
n <- length(x)
if (n == 0) {
x
} else {
p <- sample(n, 1)
smaller <- foreach(y=x[-p], .combine=c) %:% when(y <= x[p]) %do% y
larger <- foreach(y=x[-p], .combine=c) %:% when(y > x[p]) %do% y
c(qsort(smaller), x[p], qsort(larger))
}
}
qsort(runif(12))
## [1] 0.05671936 0.05986948 0.19082846 0.22652967 0.54588779 0.62601549
## [7] 0.66316703 0.68171436 0.74671367 0.80146286 0.80993460 0.82453758
No es que recomiende esto sobre la sortfunción R estándar. Pero es un ejemplo bastante interesante de uso de foreach.
Gran parte de la computación paralela consiste en hacer tres cosas: dividir el problema en partes, ejecutar las partes en paralelo y volver a combinar los resultados. Usando el foreachpaquete, los iteradores lo ayudan a dividir el problema en partes, la %dopar%función ejecuta las partes en paralelo y la .combinefunción especificada vuelve a juntar los resultados. Hemos demostrado cómo se pueden hacer cosas simples en paralelo con bastante facilidad usando el foreachpaquete y hemos dado algunas ideas sobre cómo se pueden resolver problemas más complejos. Pero es un paquete bastante nuevo, y continuaremos trabajando en formas de convertirlo en un sistema más poderoso para realizar computación paralela.