Beneficios de rendimiento de encadenar sobre AND cuando se filtra una tabla de datos

12

Tengo la costumbre de agrupar tareas similares en una sola línea. Por ejemplo, si necesito filtrar a, by cen una tabla de datos, voy a poner juntos en un solo []con AND. Ayer, noté que en mi caso particular esto era increíblemente lento y probé encadenar filtros. He incluido un ejemplo a continuación.

Primero, generador de números aleatorios, cargo y creo un conjunto de datos ficticios.

# Set RNG seed
set.seed(-1)

# Load libraries
library(data.table)

# Create data table
dt <- data.table(a = sample(1:1000, 1e7, replace = TRUE),
                 b = sample(1:1000, 1e7, replace = TRUE),
                 c = sample(1:1000, 1e7, replace = TRUE),
                 d = runif(1e7))

A continuación, defino mis métodos. El primer enfoque encadena los filtros juntos. El segundo Y une los filtros.

# Chaining method
chain_filter <- function(){
  dt[a %between% c(1, 10)
     ][b %between% c(100, 110)
       ][c %between% c(750, 760)]
}

# Anding method
and_filter <- function(){
  dt[a %between% c(1, 10) & b %between% c(100, 110) & c %between% c(750, 760)]
}

Aquí, compruebo que dan los mismos resultados.

# Check both give same result
identical(chain_filter(), and_filter())
#> [1] TRUE

Finalmente, los comparo.

# Benchmark
microbenchmark::microbenchmark(chain_filter(), and_filter())
#> Unit: milliseconds
#>            expr      min        lq      mean    median        uq       max
#>  chain_filter() 25.17734  31.24489  39.44092  37.53919  43.51588  78.12492
#>    and_filter() 92.66411 112.06136 130.92834 127.64009 149.17320 206.61777
#>  neval cld
#>    100  a 
#>    100   b

Creado el 25/10/2019 por el paquete reprex (v0.3.0)

En este caso, el encadenamiento reduce el tiempo de ejecución en aproximadamente un 70%. ¿Por qué es este el caso? Quiero decir, ¿qué está pasando bajo el capó en la tabla de datos? No he visto ninguna advertencia contra el uso &, por lo que me sorprendió que la diferencia sea tan grande. En ambos casos, evalúan las mismas condiciones, por lo que eso no debería ser una diferencia. En el caso AND, &es un operador rápido y luego solo tiene que filtrar la tabla de datos una vez (es decir, usando el vector lógico resultante de los AND), en lugar de filtrar tres veces en el caso de encadenamiento.

Pregunta extra

¿Este principio es válido para las operaciones de la tabla de datos en general? ¿Las tareas de modularización son siempre una mejor estrategia?

Lyngbakr
fuente
1
Lo mismo hago con esta observación, me he preguntado lo mismo. En mi experiencia, el aumento de la velocidad de encadenamiento se observa en las operaciones generales.
JDG
99
mientras data.tavle hace algunas optimizaciones para casos como este (¡esto solo es una hazaña y una gran mejora en comparación con la base R!), en general, A & B & C & D evaluará todas las N condiciones lógicas antes de combinar los resultados y el filtrado . mientras que con el encadenamiento las llamadas lógicas segunda, tercera y cuarta solo se evalúan n veces (donde n <= N es el número de filas restantes después de cada condición)
MichaelChirico
@MichaelChirico WOW. Eso es sorprendente! No sé por qué, pero supuse que funcionaría como un cortocircuito en C ++
duckmayr
Siguiendo el comentario de @ MichaelChirico, puede hacer una baseobservación similar con vectores haciendo lo siguiente: chain_vec <- function() { x <- which(a < .001); x[which(b[x] > .999)] }y and_vec <- function() { which(a < .001 & b > .999) }. (donde ay bson vectores de la misma longitud desde runif- Utilicé n = 1e7para estos puntos de corte).
ClancyStats
@MichaelChirico Ah, ya veo. Entonces, la gran diferencia es que en cada paso de la cadena, la tabla de datos es sustancialmente más pequeña y, por lo tanto, más rápida para evaluar la condición y filtrar. Eso tiene sentido. Gracias por tus ideas!
Lyngbakr

Respuestas:

8

Principalmente, la respuesta se dio en los comentarios aleady: el "método de encadenamiento" data.tablees más rápido en este caso que el "método de encadenamiento" ya que el encadenamiento ejecuta las condiciones una tras otra. A medida que cada paso reduce el tamaño del data.tablehay menos para evaluar para el siguiente. "Anding" evalúa las condiciones para los datos de tamaño completo cada vez.

Podemos demostrar esto con un ejemplo: cuando los pasos individuales NO disminuyen el tamaño de la data.table(es decir, las condiciones para verificar son las mismas para ambos retrasos):

chain_filter <- function(){
  dt[a %between% c(1, 1000) # runs evaluation but does not filter out cases
     ][b %between% c(1, 1000)
       ][c %between% c(750, 760)]
}

# Anding method
and_filter <- function(){
  dt[a %between% c(1, 1000) & b %between% c(1, 1000) & c %between% c(750, 760)]
}

Usando los mismos datos pero el benchpaquete, que comprueba automáticamente si los resultados son idénticos:

res <- bench::mark(
  chain = chain_filter(),
  and = and_filter()
)
summary(res)
#> # A tibble: 2 x 6
#>   expression      min   median `itr/sec` mem_alloc `gc/sec`
#>   <bch:expr> <bch:tm> <bch:tm>     <dbl> <bch:byt>    <dbl>
#> 1 chain         299ms    307ms      3.26     691MB     9.78
#> 2 and           123ms    142ms      7.18     231MB     5.39
summary(res, relative = TRUE)
#> # A tibble: 2 x 6
#>   expression   min median `itr/sec` mem_alloc `gc/sec`
#>   <bch:expr> <dbl>  <dbl>     <dbl>     <dbl>    <dbl>
#> 1 chain       2.43   2.16      1         2.99     1.82
#> 2 and         1      1         2.20      1        1

Como puede ver aquí, el enfoque anding es 2.43 veces más rápido en este caso . Eso significa que el encadenamiento en realidad agrega algo de sobrecarga , lo que sugiere que generalmente el anding debería ser más rápido. EXCEPTO si las condiciones reducen el tamaño deldata.table paso a paso. Teóricamente, el enfoque de encadenamiento podría incluso ser más lento (incluso dejando a un lado la sobrecarga), es decir, si una condición aumentara el tamaño de los datos. Pero prácticamente creo que eso no es posible ya que no se permite el reciclaje de vectores lógicos data.table. Creo que esto responde a tu pregunta extra.

A modo de comparación, las funciones originales en mi máquina con bench:

res <- bench::mark(
  chain = chain_filter_original(),
  and = and_filter_original()
)
summary(res)
#> # A tibble: 2 x 6
#>   expression      min   median `itr/sec` mem_alloc `gc/sec`
#>   <bch:expr> <bch:tm> <bch:tm>     <dbl> <bch:byt>    <dbl>
#> 1 chain        29.6ms   30.2ms     28.5     79.5MB     7.60
#> 2 and         125.5ms  136.7ms      7.32   228.9MB     7.32
summary(res, relative = TRUE)
#> # A tibble: 2 x 6
#>   expression   min median `itr/sec` mem_alloc `gc/sec`
#>   <bch:expr> <dbl>  <dbl>     <dbl>     <dbl>    <dbl>
#> 1 chain       1      1         3.89      1        1.04
#> 2 and         4.25   4.52      1         2.88     1
JBGruber
fuente