Vantaggi prestazionali del concatenamento su ANDing durante il filtraggio di una tabella di dati


12

Ho l'abitudine di raggruppare compiti simili in un'unica linea. Ad esempio, se devo filtrare su a, be cin una tabella di dati, li metterò insieme in uno []con AND. Ieri, ho notato che nel mio caso particolare questo è stato incredibilmente lento e testato invece i filtri di concatenamento. Ho incluso un esempio di seguito.

Innanzitutto, il seeding del generatore di numeri casuali, carico e creo un set di dati fittizio.

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

Successivamente, definisco i miei metodi. Le prime catene di approccio si filtrano insieme. Il secondo AND riunisce i filtri.

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

Qui, controllo che danno gli stessi risultati.

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

Infine, li benchmark.

# 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

Creato il 25-10-2019 dal pacchetto reprex (v0.3.0)

In questo caso, il concatenamento riduce il tempo di esecuzione di circa il 70%. Perché è così? Voglio dire, cosa sta succedendo sotto il cofano nella tabella dei dati? Non ho visto alcun avvertimento contro l'utilizzo &, quindi sono rimasto sorpreso dal fatto che la differenza sia così grande. In entrambi i casi valutano le stesse condizioni, quindi non dovrebbe fare differenza. Nel caso AND, &è un operatore rapido e quindi deve filtrare la tabella di dati una sola volta (ovvero, utilizzando il vettore logico risultante dagli AND), anziché filtrare tre volte nel caso concatenato.

Domanda bonus

Questo principio vale per le operazioni della tabella dei dati in generale? Le attività di modularizzazione sono sempre una strategia migliore?


1
Idem questa osservazione, mi sono chiesto lo stesso. Nella mia esperienza, la raccolta della velocità di concatenamento viene osservata durante le operazioni generali.
JDG,

9
mentre data.tavle fa alcune ottimizzazioni per casi come questo (questo da solo è un'impresa e un grande miglioramento rispetto alla base R!), in generale A & B & C & D valuteranno tutte le N condizioni logiche volte prima di combinare i risultati e filtrare . mentre con il concatenamento della seconda e terza chiamata logica vengono valutate solo n volte (dove n <= N è il numero di righe rimanenti dopo ogni condizione)
MichaelChirico,

@MichaelChirico WOW. Questo è sorprendente! Non so perché, ma ho solo pensato che avrebbe funzionato come un cortocircuito in C ++
duckmayr,

Seguendo il commento di @MicheleChirico, puoi fare baseun'osservazione simile con i vettori facendo quanto segue: chain_vec <- function() { x <- which(a < .001); x[which(b[x] > .999)] }e and_vec <- function() { which(a < .001 & b > .999) }. (dove ae bsono vettori della stessa lunghezza da runif- ho usato n = 1e7per questi tagli).
ClancyStats,

@MichaelChirico Ah, capisco. Quindi, la grande differenza è che in ogni fase della catena, la tabella dei dati è sostanzialmente più piccola e quindi più veloce nel valutare la condizione e il filtro? Questo ha senso. Grazie per le tue opinioni!
Lyngbakr,

Risposte:


8

Soprattutto, la risposta è stata data nei commenti: il "metodo di concatenamento" data.tableè più veloce in questo caso rispetto al "metodo di anding" poiché il concatenamento gestisce le condizioni una dopo l'altra. Poiché ogni passaggio riduce la dimensione di quello data.tablec'è meno da valutare per il prossimo. "Anding" valuta ogni volta le condizioni per i dati a dimensione intera.

Possiamo dimostrarlo con un esempio: quando i singoli passaggi NON riducono le dimensioni del data.table(ovvero le condizioni da verificare sono le stesse per entrambe le valutazioni):

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

Utilizzando gli stessi dati ma il benchpacchetto, che controlla automaticamente se i risultati sono identici:

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

Come puoi vedere qui, l' approccio anding è 2.43 volte più veloce in questo caso . Ciò significa che il concatenamento in realtà aggiunge un certo sovraccarico , suggerendo che solitamente anding dovrebbe essere più veloce. TRANNE se le condizioni stanno riducendo le dimensioni deldata.table passaggio per passaggio. Teoricamente, l'approccio concatenato potrebbe anche essere più lento (anche lasciando da parte l'overhead), vale a dire se una condizione aumenterebbe la dimensione dei dati. Ma praticamente penso che ciò non sia possibile poiché non è consentito riciclare vettori logici data.table. Penso che questo risponda alla tua domanda bonus.

Per confronto, le funzioni originali sulla mia macchina 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
Utilizzando il nostro sito, riconosci di aver letto e compreso le nostre Informativa sui cookie e Informativa sulla privacy.
Licensed under cc by-sa 3.0 with attribution required.