Benefícios de desempenho do encadeamento sobre ANDing ao filtrar uma tabela de dados

12

Eu tenho o hábito de agrupar tarefas semelhantes em uma única linha. Por exemplo, se eu preciso filtrar a, be cem uma tabela de dados, eu vou colocá-los juntos em um []com ANDs. Ontem, notei que, no meu caso particular, isso era incrivelmente lento e testou os filtros de encadeamento. Eu incluí um exemplo abaixo.

Primeiro, eu gerador de números aleatórios, carrego e crio um conjunto de dados fictícios.

# 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))

Em seguida, defino meus métodos. A primeira abordagem encadeia os filtros juntos. O segundo ANDs os filtros juntos.

# 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)]
}

Aqui, verifico se eles dão os mesmos resultados.

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

Finalmente, eu os 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

Criado em 2019-10-25 pelo pacote reprex (v0.3.0)

Nesse caso, o encadeamento reduz o tempo de execução em cerca de 70%. Por que esse é o caso? Quero dizer, o que está acontecendo sob o capô na tabela de dados? Não vi nenhum aviso contra o uso &, então fiquei surpreso que a diferença seja tão grande. Nos dois casos, eles avaliam as mesmas condições, de modo que não deve haver diferença. No caso AND, &é um operador rápido e só precisa filtrar a tabela de dados uma vez (ou seja, usando o vetor lógico resultante dos ANDs), em vez de filtrar três vezes no caso encadeamento.

Pergunta bônus

Esse princípio é válido para as operações da tabela de dados em geral? As tarefas de modularização são sempre uma estratégia melhor?

Lyngbakr
fonte
11
O mesmo faço com essa observação. Na minha experiência, a aceleração da cadeia é observada em todas as operações gerais.
JDG 25/10/19
9
Embora o data.tavle faça algumas otimizações para casos como este (isso por si só é uma façanha e uma grande melhoria em relação à base R!), em geral, A & B & C & D avaliarão todas as N condições de tempo vezes antes de combinar os resultados e a filtragem . enquanto que com o encadeamento do 2º 3º e chamadas lógicas 4ª só são avaliadas n vezes (onde n <= N é o número de linhas remanescente após cada condição)
MichaelChirico
@MichaelChirico WOW. Isso é surpreendente! Eu não sei porque, mas eu só assumiu que iria funcionar como C ++ curto-circuito
duckmayr
Seguindo o comentário de @ MichaelChirico, você pode fazer uma baseobservação semelhante com vetores, fazendo o seguinte: chain_vec <- function() { x <- which(a < .001); x[which(b[x] > .999)] }e and_vec <- function() { which(a < .001 & b > .999) }. (onde ae bsão vetores do mesmo comprimento de runif- usei n = 1e7para esses pontos de corte).
ClancyStats
@MichaelChirico Ah, entendo. Portanto, a grande diferença é que, em cada etapa da cadeia, a tabela de dados é substancialmente menor e, portanto, mais rápida para avaliar a condição e filtrar? Isso faz sentido. Obrigado por suas idéias!
Lyngbakr

Respostas:

8

Principalmente, a resposta foi dada nos comentários já: data.tableneste caso, o "método de encadeamento" é mais rápido que o "método de anding", pois o encadeamento executa as condições uma após a outra. Como cada etapa reduz o tamanho da, data.tablehá menos para avaliar na próxima. "Anding" avalia as condições para os dados em tamanho real a cada vez.

Podemos demonstrar isso com um exemplo: quando as etapas individuais NÃO diminuem o tamanho da data.table(ou seja, as condições a serem verificadas são as mesmas para as duas abordagens):

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 os mesmos dados, mas o benchpacote, que verifica automaticamente se os resultados são 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 você pode ver aqui, a abordagem anding é 2,43 vezes mais rápida nesse caso . Isso significa que o encadeamento realmente adiciona alguma sobrecarga , sugerindo que o anding geralmente deve ser mais rápido. EXCETO se as condições estiverem reduzindo o tamanho dodata.table passo a passo. Teoricamente, a abordagem de encadeamento pode até ser mais lenta (mesmo deixando a sobrecarga de lado), ou seja, se uma condição aumentaria o tamanho dos dados. Mas praticamente acho que isso não é possível, uma vez que a reciclagem de vetores lógicos não é permitida data.table. Acho que isso responde à sua pergunta sobre bônus.

Para comparação, funções originais na minha máquina com 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
fonte