24  Mejorando el desempeño

24.1 Introducción

Deberíamos olvidarnos de las pequeñas eficiencias, digamos alrededor del 97% del tiempo: la optimización prematura es la raíz de todos los males. Sin embargo, no debemos dejar pasar nuestras oportunidades en ese crítico 3%. Un buen programador no se dejará llevar por la complacencia de tal razonamiento, será prudente al mirar cuidadosamente el código crítico; pero solo después de que ese código haya sido identificado.

— Donald Knuth

Una vez que haya utilizado la creación de perfiles para identificar un cuello de botella, debe hacerlo más rápido. Es difícil dar consejos generales sobre cómo mejorar el rendimiento, pero hago lo mejor que puedo con cuatro técnicas que se pueden aplicar en muchas situaciones. También sugeriré una estrategia general para la optimización del rendimiento que ayude a garantizar que su código más rápido siga siendo correcto.

Es fácil quedar atrapado tratando de eliminar todos los cuellos de botella. ¡No! Su tiempo es valioso y es mejor gastarlo analizando sus datos, no eliminando posibles ineficiencias en su código. Sea pragmático: no gaste horas de su tiempo para ahorrar segundos de tiempo de computadora. Para hacer cumplir este consejo, debe establecer un objetivo de tiempo para su código y optimizar solo hasta ese objetivo. Esto significa que no eliminará todos los cuellos de botella. Algunas no las alcanzarás porque has cumplido tu objetivo. Es posible que deba pasar por alto otros y aceptarlos porque no hay una solución rápida y fácil o porque el código ya está bien optimizado y no es posible una mejora significativa. Acepte estas posibilidades y pase al siguiente candidato.

Si desea obtener más información sobre las características de rendimiento del lenguaje R, le recomiendo Evaluar el diseño del lenguaje R (Morandat et al. 2012). Saca conclusiones al combinar un intérprete R modificado con un amplio conjunto de código que se encuentra en la naturaleza.

Estructura

  • La Sección 24.2 le enseña cómo organizar su código para que la optimización sea lo más fácil y libre de errores posible.

  • La Sección 24.3 le recuerda que busque las soluciones existentes.

  • La Sección 24.4 enfatiza la importancia de ser perezoso: a menudo, la forma más fácil de hacer una función más rápida es dejar que haga menos trabajo.

  • La Sección 24.5 define de forma concisa la vectorización y le muestra cómo aprovechar al máximo las funciones integradas.

  • La Sección 24.6 analiza los peligros de rendimiento de la copia de datos.

  • La Sección 24.7 reúne todas las piezas en un estudio de caso que muestra cómo acelerar las pruebas t repetidas unas mil veces.

  • La Sección 24.8 termina el capítulo con indicaciones a más recursos que lo ayudarán a escribir código rápido.

Requisitos previos

Usaremos bench para comparar con precisión el rendimiento de pequeños fragmentos de código independientes.

library(bench)

24.2 Organización del código

Hay dos trampas en las que es fácil caer cuando intentas hacer tu código más rápido:

  1. Escribir código más rápido pero incorrecto.
  2. Escribir código que crees que es más rápido, pero que en realidad no es mejor.

La estrategia descrita a continuación le ayudará a evitar estas trampas.

Al abordar un cuello de botella, es probable que encuentre múltiples enfoques. Escriba una función para cada enfoque, encapsulando todo el comportamiento relevante. Esto hace que sea más fácil verificar que cada enfoque devuelva el resultado correcto y cronometrar cuánto tiempo lleva ejecutarse. Para demostrar la estrategia, compararé dos enfoques para calcular la media:

mean1 <- function(x) mean(x)
mean2 <- function(x) sum(x) / length(x)

Te recomiendo que lleves un registro de todo lo que intentes, incluso de los fracasos. Si ocurre un problema similar en el futuro, será útil ver todo lo que ha intentado. Para hacer esto, recomiendo RMarkdown, que facilita la combinación de código con comentarios y notas detallados.

A continuación, genere un caso de prueba representativo. El caso debe ser lo suficientemente grande para capturar la esencia de su problema, pero lo suficientemente pequeño como para que solo tome unos segundos como máximo. No desea que tarde demasiado porque necesitará ejecutar el caso de prueba muchas veces para comparar enfoques. Por otro lado, no desea que el caso sea demasiado pequeño porque es posible que los resultados no alcancen el problema real. Aquí voy a usar 100,000 números:

x <- runif(1e5)

Ahora usa bench::mark() para comparar con precisión las variaciones. bench::mark() verifica automáticamente que todas las llamadas devuelvan los mismos valores. Esto no garantiza que la función se comporte de la misma manera para todas las entradas, por lo que en un mundo ideal también tendrá pruebas unitarias para asegurarse de no cambiar accidentalmente el comportamiento de la función.

bench::mark(
  mean1(x),
  mean2(x)
)[c("expression", "min", "median", "itr/sec", "n_gc")]
#> # A tibble: 2 × 4
#>   expression      min   median `itr/sec`
#>   <bch:expr> <bch:tm> <bch:tm>     <dbl>
#> 1 mean1(x)      425µs    437µs     2277.
#> 2 mean2(x)      187µs    187µs     5310.

(Puede que te sorprendan los resultados: mean(x) es considerablemente más lento que sum(x) / length(x). Esto se debe a que, entre otras razones, mean(x) hace dos pasadas sobre el vector para que sea numéricamente más preciso.)

Si desea ver esta estrategia en acción, la he usado varias veces en stackoverflow:

24.3 Comprobación de soluciones existentes

Una vez que haya organizado su código y capturado todas las variaciones que se le ocurran, es natural ver lo que otros han hecho. Eres parte de una gran comunidad y es muy posible que alguien ya haya abordado el mismo problema. Dos buenos lugares para comenzar son:

  • CRAN task views. Si hay una vista de tareas CRAN relacionada con el dominio de su problema, vale la pena mirar los paquetes enumerados allí.

  • Dependencias inversas de Rcpp, como se indica en su página de CRAN. Dado que estos paquetes usan C++, es probable que sean rápidos.

De lo contrario, el desafío es describir su cuello de botella de una manera que lo ayude a encontrar problemas y soluciones relacionados. Saber el nombre del problema o sus sinónimos hará que esta búsqueda sea mucho más fácil. Pero como no sabes cómo se llama, ¡es difícil buscarlo! La mejor manera de resolver este problema es leer mucho para que puedas construir tu propio vocabulario con el tiempo. Alternativamente, pregunte a otros. Hable con sus colegas y haga una lluvia de ideas sobre algunos nombres posibles, luego busque en Google y StackOverflow. Suele ser útil restringir la búsqueda a páginas relacionadas con R. Para Google, pruebe rseek. Para stackoverflow, restrinja su búsqueda incluyendo la etiqueta R, [R], en su búsqueda.

Registre todas las soluciones que encuentre, no solo aquellas que parezcan ser más rápidas inmediatamente. Algunas soluciones pueden ser más lentas inicialmente, pero terminan siendo más rápidas porque son más fáciles de optimizar. También puede combinar las partes más rápidas desde diferentes enfoques. Si ha encontrado una solución lo suficientemente rápida, ¡felicidades! De lo contrario, sigue leyendo.

24.3.1 Ejercicios

  1. ¿Cuáles son las alternativas más rápidas a lm()? ¿Cuáles están diseñados específicamente para trabajar con conjuntos de datos más grandes?

  2. ¿Qué paquete implementa una versión de match() que es más rápida para búsquedas repetidas? ¿Cuánto más rápido es?

  3. Enumere cuatro funciones (no solo las de base R) que convierten una cadena en un objeto de fecha y hora. Cuales son sus fortalezas y debilidades?

  4. ¿Qué paquetes brindan la capacidad de calcular una media móvil?

  5. ¿Cuáles son las alternativas a optim()?

24.4 Haciendo lo menos posible

La forma más fácil de hacer que una función sea más rápida es dejar que haga menos trabajo. Una forma de hacerlo es usar una función adaptada a un tipo de entrada o salida más específico, o a un problema más específico. Por ejemplo:

  • rowSums(), colSums(), rowMeans(), y colMeans() son más rápidas que las invocaciones equivalentes que usan apply() porque están vectorizadas (Sección 24.5).

  • vapply() es más rápido que sapply() porque especifica previamente el tipo de salida.

  • Si quiere ver si un vector contiene un solo valor, any(x == 10) es mucho más rápido que 10 %in% x porque probar la igualdad es más simple que probar la inclusión de conjuntos.

Tener este conocimiento al alcance de la mano requiere saber que existen funciones alternativas: es necesario tener un buen vocabulario. Amplíe su vocabulario leyendo regularmente el código R. Buenos lugares para leer código son la lista de correo de R-help y StackOverflow.

Algunas funciones obligan a sus entradas a un tipo específico. Si su entrada no es del tipo correcto, la función tiene que hacer un trabajo extra. En su lugar, busque una función que funcione con sus datos tal como están, o considere cambiar la forma en que almacena sus datos. El ejemplo más común de este problema es usar apply() en un marco de datos. apply() siempre convierte su entrada en una matriz. No solo es propenso a errores (porque un marco de datos es más general que una matriz), sino que también es más lento.

Otras funciones harán menos trabajo si les proporciona más información sobre el problema. Siempre vale la pena leer detenidamente la documentación y experimentar con diferentes argumentos. Algunos ejemplos que he descubierto en el pasado incluyen:

  • read.csv(): especificar tipos de columnas conocidas con colClasses. (También considere cambiar a readr::read_csv() o data.table::fread() que son considerablemente más rápidos que read.csv().)

  • factor(): especificar niveles conocidos con levels.

  • cut(): no genere etiquetas con labels = FALSE si no las necesita o, mejor aún, use findInterval() como se menciona en la sección “ver también” de la documentación.

  • unlist(x, use.names = FALSE) es mucho más rápido que unlist(x).

  • interaction(): si solo necesita combinaciones que existen en los datos, use drop = TRUE.

A continuación, exploro cómo podría mejorar la aplicación de esta estrategia para mejorar el rendimiento de mean() y as.data.frame().

24.4.1 mean()

A veces, puede hacer que una función sea más rápida evitando el envío de métodos. Si está llamando a un método en un ciclo cerrado, puede evitar algunos de los costos haciendo la búsqueda del método solo una vez:

  • Para S3, puede hacer esto llamando a generic.class() en lugar de generic().

  • Para S4, puede hacer esto usando selectMethod() para encontrar el método, guardándolo en una variable y luego llamando a esa función.

Por ejemplo, llamar a mean.default() es un poco más rápido que llamar a mean() para vectores pequeños:

x <- runif(1e2)

bench::mark(
  mean(x),
  mean.default(x)
)[c("expression", "min", "median", "itr/sec", "n_gc")]
#> # A tibble: 2 × 4
#>   expression           min   median `itr/sec`
#>   <bch:expr>      <bch:tm> <bch:tm>     <dbl>
#> 1 mean(x)           2.98µs   3.21µs   301468.
#> 2 mean.default(x)   1.91µs   2.04µs   469610.

Esta optimización es un poco arriesgada. Si bien mean.default() es casi el doble de rápido para 100 valores, fallará de manera sorprendente si x no es un vector numérico.

Una optimización aún más arriesgada es llamar directamente a la función .Internal subyacente. Esto es más rápido porque no realiza ninguna verificación de entrada ni maneja NA, por lo que está comprando velocidad a costa de la seguridad.

x <- runif(1e2)
bench::mark(
  mean(x),
  mean.default(x),
  .Internal(mean(x))
)[c("expression", "min", "median", "itr/sec", "n_gc")]
#> # A tibble: 3 × 4
#>   expression              min   median `itr/sec`
#>   <bch:expr>         <bch:tm> <bch:tm>     <dbl>
#> 1 mean(x)              2.98µs   3.23µs   301475.
#> 2 mean.default(x)      1.91µs   2.04µs   472279.
#> 3 .Internal(mean(x)) 481.03ns 501.05ns  1958563.

NB: La mayoría de estas diferencias surgen porque x es pequeño. Si aumenta el tamaño, las diferencias básicamente desaparecen, porque la mayor parte del tiempo ahora se dedica a calcular la media, sin encontrar la implementación subyacente. Este es un buen recordatorio de que el tamaño de la entrada es importante y debe motivar sus optimizaciones en función de datos realistas.

x <- runif(1e4)
bench::mark(
  mean(x),
  mean.default(x),
  .Internal(mean(x))
)[c("expression", "min", "median", "itr/sec", "n_gc")]
#> # A tibble: 3 × 4
#>   expression              min   median `itr/sec`
#>   <bch:expr>         <bch:tm> <bch:tm>     <dbl>
#> 1 mean(x)              41.7µs   46.1µs    21587.
#> 2 mean.default(x)      41.2µs   44.8µs    22190.
#> 3 .Internal(mean(x))   37.2µs   43.3µs    23089.

24.4.2 as.data.frame()

Saber que está tratando con un tipo específico de entrada puede ser otra forma de escribir código más rápido. Por ejemplo, as.data.frame() es bastante lento porque convierte cada elemento en un marco de datos y luego rbind() los une. Si tiene una lista con nombre con vectores de igual longitud, puede transformarla directamente en un marco de datos. En este caso, si puede hacer suposiciones sólidas sobre su entrada, puede escribir un método que sea considerablemente más rápido que el predeterminado.

quickdf <- function(l) {
  class(l) <- "data.frame"
  attr(l, "row.names") <- .set_row_names(length(l[[1]]))
  l
}

l <- lapply(1:26, function(i) runif(1e3))
names(l) <- letters

bench::mark(
  as.data.frame = as.data.frame(l),
  quick_df      = quickdf(l)
)[c("expression", "min", "median", "itr/sec", "n_gc")]
#> # A tibble: 2 × 4
#>   expression         min   median `itr/sec`
#>   <bch:expr>    <bch:tm> <bch:tm>     <dbl>
#> 1 as.data.frame 940.02µs 983.64µs     1001.
#> 2 quick_df        6.34µs   6.95µs   136487.

Una vez más, tenga en cuenta la compensación. Este método es rápido porque es peligroso. Si le da entradas incorrectas, obtendrá un marco de datos corrupto:

quickdf(list(x = 1, y = 1:2))
#> Warning in format.data.frame(if (omit) x[seq_len(n0), , drop = FALSE] else x, :
#> corrupt data frame: columns will be truncated or padded with NAs
#>   x y
#> 1 1 1

Para llegar a este método mínimo, leí cuidadosamente y luego reescribí el código fuente para as.data.frame.list() y data.frame(). Hice muchos pequeños cambios, comprobando cada vez que no había roto el comportamiento existente. Después de varias horas de trabajo, pude aislar el código mínimo que se muestra arriba. Esta es una técnica muy útil. La mayoría de las funciones básicas de R están escritas para la flexibilidad y la funcionalidad, no para el rendimiento. Por lo tanto, reescribir para su necesidad específica a menudo puede generar mejoras sustanciales. Para hacer esto, deberá leer el código fuente. Puede ser complejo y confuso, ¡pero no te rindas!

24.4.3 Ejercicios

  1. ¿Cuál es la diferencia entre rowSums() y .rowSums()?

  2. Cree una versión más rápida de chisq.test() que solo calcula la estadística de prueba de chi-cuadrado cuando la entrada son dos vectores numéricos sin valores faltantes. Puede intentar simplificar chisq.test() o codificar desde la definición matemática.

  3. ¿Puedes hacer una versión más rápida de table() para el caso de una entrada de dos vectores enteros sin valores perdidos? ¿Puedes usarlo para acelerar tu prueba de chi-cuadrado?

24.5 Vectorizar

Si ha usado R durante algún tiempo, probablemente haya escuchado la advertencia de “vectorizar su código”. Pero, ¿qué significa eso realmente? Vectorizar su código no se trata solo de evitar bucles for, aunque eso suele ser un paso. Vectorizar se trata de adoptar un enfoque de objeto completo para un problema, pensando en vectores, no en escalares. Hay dos atributos clave de una función vectorizada:

  • Simplifica muchos problemas. En lugar de tener que pensar en los componentes de un vector, solo piensa en vectores completos.

  • Los bucles en una función vectorizada están escritos en C en lugar de R. Los bucles en C son mucho más rápidos porque tienen mucha menos sobrecarga.

El Capítulo 9 hizo hincapié en la importancia del código vectorizado como una abstracción de mayor nivel. La vectorización también es importante para escribir código R rápido. Esto no significa simplemente usar map() o lapply(). En cambio, la vectorización significa encontrar la función R existente que se implementa en C y se aplica más a su problema.

Las funciones vectorizadas que se aplican a muchos cuellos de botella de rendimiento comunes incluyen:

  • rowSums(), colSums(), rowMeans(), y colMeans(). Estas funciones matriciales vectorizadas siempre serán más rápidas que usar apply(). A veces puede usar estas funciones para construir otras funciones vectorizadas.

    rowAny <- function(x) rowSums(x) > 0
    rowAll <- function(x) rowSums(x) == ncol(x)
  • La creación de subconjuntos vectorizados puede conducir a grandes mejoras en la velocidad. Recuerde las técnicas detrás de las tablas de búsqueda (Sección 4.5.1) y la combinación y combinación manual (Sección 4.5.2). Recuerde también que puede usar la asignación de subconjuntos para reemplazar varios valores en un solo paso. Si x es un vector, una matriz o un marco de datos, entonces x[is.na(x)] <- 0 reemplazará todos los valores faltantes con 0.

  • Si está extrayendo o reemplazando valores en ubicaciones dispersas en una matriz o marco de datos, subconjunto con una matriz de enteros. Consulte Sección 4.2.3 para obtener más detalles.

  • Si está convirtiendo valores continuos a categóricos, asegúrese de saber cómo usar cut() y findInterval().

  • Tenga en cuenta las funciones vectorizadas como cumsum() y diff().

El álgebra matricial es un ejemplo general de vectorización. Estos bucles son ejecutados por bibliotecas externas altamente optimizadas como BLAS. Si puede encontrar una manera de usar el álgebra matricial para resolver su problema, a menudo obtendrá una solución muy rápida. La habilidad para resolver problemas con álgebra matricial es producto de la experiencia. Un buen lugar para comenzar es preguntar a personas con experiencia en su dominio.

La vectorización tiene un inconveniente: es más difícil predecir cómo escalarán las operaciones. El siguiente ejemplo mide cuánto tiempo lleva usar subconjuntos de caracteres para buscar 1, 10 y 100 elementos de una lista. Podría esperar que buscar 10 elementos tomara 10 veces más que buscar 1, y que buscar 100 elementos tomaría 10 veces más de nuevo. De hecho, el siguiente ejemplo muestra que solo se tarda aproximadamente ~10 veces más en buscar 100 elementos que en buscar 1. Eso sucede porque una vez que llega a un cierto tamaño, la implementación interna cambia a una estrategia que tiene un mayor costo de instalación, pero escala mejor.

lookup <- setNames(as.list(sample(100, 26)), letters)

x1 <- "j"
x10 <- sample(letters, 10)
x100 <- sample(letters, 100, replace = TRUE)

bench::mark(
  lookup[x1],
  lookup[x10],
  lookup[x100],
  check = FALSE
)[c("expression", "min", "median", "itr/sec", "n_gc")]
#> # A tibble: 3 × 4
#>   expression        min   median `itr/sec`
#>   <bch:expr>   <bch:tm> <bch:tm>     <dbl>
#> 1 lookup[x1]   430.04ns  471.1ns  1942129.
#> 2 lookup[x10]     1.2µs    1.3µs   732171.
#> 3 lookup[x100]   2.87µs    4.7µs   214183.

La vectorización no resolverá todos los problemas y, en lugar de convertir un algoritmo existente en uno que utilice un enfoque vectorizado, a menudo es mejor escribir su propia función vectorizada en C++. Aprenderá cómo hacerlo en el Capítulo 25.

24.5.1 Ejercicios

  1. Las funciones de densidad, por ejemplo, dnorm(), tienen una interfaz común. ¿Qué argumentos se vectorizan? ¿Qué hace rnorm(10, mean = 10:1)?

  2. Compara la velocidad de apply(x, 1, sum) con rowSums(x) para diferentes tamaños de x.

  3. ¿Cómo puedes usar crossprod() para calcular una suma ponderada? ¿Cuánto más rápido es que el ingenuo sum(x * w)?

24.6 Evitar copias

Una fuente perniciosa de código R lento es hacer crecer un objeto con un bucle. Siempre que use c(), append(), cbind(), rbind() o paste() para crear un objeto más grande, R primero debe asignar espacio para el nuevo objeto y luego copiar el objeto antiguo a su nuevo hogar. Si repite esto muchas veces, como en un ciclo for, esto puede ser bastante costoso. Has entrado en el Círculo 2 del R inferno.

Viste un ejemplo de este tipo de problema en la Sección 23.2.2, así que aquí mostraré un ejemplo un poco más complejo del mismo problema básico. Primero generamos algunas cadenas aleatorias y luego las combinamos iterativamente con un ciclo usando collapse(), o en un solo paso usando paste(). Tenga en cuenta que el rendimiento de collapse() empeora relativamente a medida que aumenta el número de cadenas: combinar 100 cadenas lleva casi 30 veces más que combinar 10 cadenas.

random_string <- function() {
  paste(sample(letters, 50, replace = TRUE), collapse = "")
}
strings10 <- replicate(10, random_string())
strings100 <- replicate(100, random_string())

collapse <- function(xs) {
  out <- ""
  for (x in xs) {
    out <- paste0(out, x)
  }
  out
}

bench::mark(
  loop10  = collapse(strings10),
  loop100 = collapse(strings100),
  vec10   = paste(strings10, collapse = ""),
  vec100  = paste(strings100, collapse = ""),
  check = FALSE
)[c("expression", "min", "median", "itr/sec", "n_gc")]
#> # A tibble: 4 × 4
#>   expression      min   median `itr/sec`
#>   <bch:expr> <bch:tm> <bch:tm>     <dbl>
#> 1 loop10      19.43µs  20.64µs    47717.
#> 2 loop100    520.17µs 533.21µs     1840.
#> 3 vec10        3.59µs   3.89µs   253323.
#> 4 vec100      22.51µs  23.02µs    42669.

Modificar un objeto en un bucle, por ejemplo, x[i] <- y, también puede crear una copia, dependiendo de la clase de x. La Sección 2.5.1 analiza este problema con mayor profundidad y le brinda algunas herramientas para determinar cuándo está haciendo copias.

24.7 Caso de estudio: t-test

El siguiente estudio de caso muestra cómo hacer que las pruebas t sean más rápidas utilizando algunas de las técnicas descritas anteriormente. Se basa en un ejemplo de Cálculo de miles de estadísticas de prueba simultáneamente en R de Holger Schwender y Tina Müller. Recomiendo encarecidamente leer el documento completo para ver la misma idea aplicada a otras pruebas.

Imagine que hemos realizado 1000 experimentos (filas), cada uno de los cuales recopila datos de 50 individuos (columnas). Los primeros 25 individuos de cada experimento se asignan al grupo 1 y el resto al grupo 2. Primero generaremos algunos datos aleatorios para representar este problema:

m <- 1000
n <- 50
X <- matrix(rnorm(m * n, mean = 10, sd = 3), nrow = m)
grp <- rep(1:2, each = n / 2)

Para los datos en este formulario, hay dos formas de usar t.test(). Podemos usar la interfaz de fórmula o proporcionar dos vectores, uno para cada grupo. El tiempo revela que la interfaz de la fórmula es considerablemente más lenta.

system.time(
  for (i in 1:m) {
    t.test(X[i, ] ~ grp)$statistic
  }
)
#>    user  system elapsed 
#>   0.395   0.000   0.395
system.time(
  for (i in 1:m) {
    t.test(X[i, grp == 1], X[i, grp == 2])$statistic
  }
)
#>    user  system elapsed 
#>   0.111   0.000   0.112

Por supuesto, un bucle for calcula, pero no guarda los valores. Podemos map_dbl() (Sección 9.2.1) para hacer eso. Esto agrega un poco de sobrecarga:

compT <- function(i){
  t.test(X[i, grp == 1], X[i, grp == 2])$statistic
}
system.time(t1 <- purrr::map_dbl(1:m, compT))
#>    user  system elapsed 
#>   0.123   0.000   0.122

¿Cómo podemos hacer esto más rápido? Primero, podríamos intentar hacer menos trabajo. Si observa el código fuente de stats:::t.test.default(), verá que hace mucho más que calcular la estadística t. También calcula el valor p y formatea la salida para su impresión. Podemos intentar que nuestro código sea más rápido eliminando esas piezas.

my_t <- function(x, grp) {
  t_stat <- function(x) {
    m <- mean(x)
    n <- length(x)
    var <- sum((x - m) ^ 2) / (n - 1)

    list(m = m, n = n, var = var)
  }

  g1 <- t_stat(x[grp == 1])
  g2 <- t_stat(x[grp == 2])

  se_total <- sqrt(g1$var / g1$n + g2$var / g2$n)
  (g1$m - g2$m) / se_total
}

system.time(t2 <- purrr::map_dbl(1:m, ~ my_t(X[.,], grp)))
#>    user  system elapsed 
#>   0.023   0.000   0.024
stopifnot(all.equal(t1, t2))

Esto nos da una mejora de velocidad de seis veces.

Ahora que tenemos una función bastante simple, podemos hacerla aún más rápida al vectorizarla. En lugar de recorrer la matriz fuera de la función, modificaremos t_stat() para que funcione con una matriz de valores. Por lo tanto, mean() se convierte en rowMeans(), length() se convierte en ncol() y sum() se convierte en rowSums(). El resto del código permanece igual.

rowtstat <- function(X, grp){
  t_stat <- function(X) {
    m <- rowMeans(X)
    n <- ncol(X)
    var <- rowSums((X - m) ^ 2) / (n - 1)

    list(m = m, n = n, var = var)
  }

  g1 <- t_stat(X[, grp == 1])
  g2 <- t_stat(X[, grp == 2])

  se_total <- sqrt(g1$var / g1$n + g2$var / g2$n)
  (g1$m - g2$m) / se_total
}
system.time(t3 <- rowtstat(X, grp))
#>    user  system elapsed 
#>   0.010   0.000   0.009
stopifnot(all.equal(t1, t3))

¡Eso es mucho más rápido! Es al menos 40 veces más rápido que nuestro esfuerzo anterior y alrededor de 1000 veces más rápido que donde comenzamos.

24.8 Otras tecnicas

Ser capaz de escribir código R rápido es parte de ser un buen programador R. Más allá de las sugerencias específicas de este capítulo, si desea escribir código R rápido, deberá mejorar sus habilidades generales de programación. Algunas formas de hacer esto son:

  • Read R blogs para ver con qué problemas de rendimiento han luchado otras personas y cómo han hecho que su código sea más rápido.

  • Lea otros libros de programación R, como El arte de la programación R (Matloff 2011) o [R Inferno] de Patrick Burns (http://www.burns-stat.com/documents/books/the -r-inferno/) para conocer las trampas comunes.

  • Tome un curso de algoritmos y estructura de datos para aprender algunas formas bien conocidas de abordar ciertas clases de problemas. Escuché cosas buenas sobre el curso de algoritmos de Princeton que se ofrece en Coursera.

  • Aprende a paralelizar tu código. Dos lugares para comenzar son Parallel R (McCallum y Weston 2011) y Parallel Computing for Data Science (Matloff 2015).

  • Lea libros generales sobre optimización como Optimización madura (Bueno 2013) o el Programador pragmático (Hunt y Thomas 1990).

También puede comunicarse con la comunidad para obtener ayuda. StackOverflow puede ser un recurso útil. Deberá esforzarse un poco para crear un ejemplo fácilmente digerible que también capture las características más destacadas de su problema. Si su ejemplo es demasiado complejo, pocas personas tendrán el tiempo y la motivación para intentar una solución. Si es demasiado simple, obtendrá respuestas que resuelven el problema del juguete pero no el problema real. Si también intenta responder preguntas en StackOverflow, rápidamente tendrá una idea de lo que constituye una buena pregunta.