dplyr en data.table, ¿realmente estoy usando data.table?

Si utilizo la syntax de dplyr sobre una tabla de datos , ¿obtengo todos los beneficios de velocidad de la tabla de datos mientras uso la syntax de dplyr? En otras palabras, ¿mal uso de la tabla de datos si la consulto con la syntax dplyr? ¿O necesito usar syntax pura datatable para aprovechar toda su potencia?

Gracias de antemano por cualquier consejo. Ejemplo de código:

library(data.table) library(dplyr) diamondsDT % filter(cut != "Fair") %>% group_by(cut) %>% summarize(AvgPrice = mean(price), MedianPrice = as.numeric(median(price)), Count = n()) %>% arrange(desc(Count)) 

Resultados:

 # cut AvgPrice MedianPrice Count # 1 Ideal 3457.542 1810.0 21551 # 2 Premium 4584.258 3185.0 13791 # 3 Very Good 3981.760 2648.0 12082 # 4 Good 3928.864 3050.5 4906 

Aquí está la equivalencia de tabla de datos que se me ocurrió. No estoy seguro de si cumple con las buenas prácticas de DT. Pero me pregunto si el código es realmente más eficiente que la syntax de Dplyr detrás de la escena:

 diamondsDT [cut != "Fair" ] [, .(AvgPrice = mean(price), MedianPrice = as.numeric(median(price)), Count = .N), by=cut ] [ order(-Count) ] 

No existe una respuesta directa / simple porque las filosofías de ambos paquetes difieren en ciertos aspectos. Entonces, algunos compromisos son inevitables. Estas son algunas de las inquietudes que puede necesitar abordar / considerar.

Operaciones que implican i (== filter() y slice() en dplyr)

Supongamos DT con digamos 10 columnas. Considere estas expresiones de data.table:

 DT[a > 1, .N] ## --- (1) DT[a > 1, mean(b), by=.(c, d)] ## --- (2) 

(1) da el número de filas en DT donde la columna a > 1 . (2) devuelve la mean(b) agrupada por c,d para la misma expresión en i como (1).

Las expresiones dplyr comúnmente usadas serían:

 DT %>% filter(a > 1) %>% summarise(n()) ## --- (3) DT %>% filter(a > 1) %>% group_by(c, d) %>% summarise(mean(b)) ## --- (4) 

Claramente, los códigos data.table son más cortos. Además, también son más eficientes con la memoria 1 . ¿Por qué? Porque en ambos (3) y (4), filter() devuelve filas para las 10 columnas primero, cuando en (3) solo necesitamos el número de filas, y en (4) solo necesitamos las columnas b, c, d para las operaciones sucesivas. Para superar esto, debemos select() columnas a priori:

 DT %>% select(a) %>% filter(a > 1) %>% summarise(n()) ## --- (5) DT %>% select(a,b,c,d) %>% filter(a > 1) %>% group_by(c,d) %>% summarise(mean(b)) ## --- (6) 

Es esencial resaltar una gran diferencia filosófica entre los dos paquetes:

  • En data.table , nos gusta mantener juntas estas operaciones relacionadas, y eso permite ver la j-expression (de la misma llamada de función) y darse cuenta de que no hay necesidad de ninguna columna en (1). La expresión en i se calcula, y .N es simplemente la sum de ese vector lógico que da el número de filas; todo el subconjunto nunca se realiza. En (2), solo las columnas b,c,d se materializan en el subconjunto, otras columnas se ignoran.

  • Pero en dplyr , la filosofía es tener una función que haga precisamente una cosa bien . No hay (al menos actualmente) forma de saber si la operación después del filter() necesita todas las columnas que filtramos. Tendrá que pensar en el futuro si desea realizar tales tareas de manera eficiente. Personalmente, me parece contraintuitivo en este caso.

Tenga en cuenta que en (5) y (6), todavía subconjunto de la columna a que no requerimos. Pero no estoy seguro de cómo evitar eso. Si la función filter() tiene un argumento para seleccionar las columnas a devolver, podríamos evitar este problema, pero luego la función no hará solo una tarea (que también es una opción de diseño dplyr).

Subasignar por referencia

dplyr nunca se actualizará por referencia. Esta es otra gran diferencia (filosófica) entre los dos paquetes.

Por ejemplo, en data.table puedes hacer:

 DT[a %in% some_vals, a := NA] 

que actualiza la columna a por referencia solo en aquellas filas que satisfacen la condición. Por el momento, dplyr copia profundamente toda la tabla de datos internamente para agregar una nueva columna. @BrodieG ya mencionó esto en su respuesta.

Pero la copia profunda puede ser reemplazada por una copia superficial cuando se implementa FR # 617 . También es relevante: dplyr: FR # 614 . Tenga en cuenta que, sin embargo, la columna que modifique siempre se copiará (por lo tanto, será más lenta / menos eficiente en la memoria). No habrá forma de actualizar columnas por referencia.

Otras funcionalidades

  • En data.table, puede agregar mientras se une, y esto es más sencillo de entender y eficiente desde el punto de vista de la memoria, ya que el resultado de la combinación intermedia nunca se materializa. Verifique esta publicación para ver un ejemplo. No puede (¿por el momento?) Hacer eso usando la syntax data.table / data.frame de dplyr.

  • La función rolling join de data.table no es compatible con la syntax de dplyr.

  • Recientemente implementamos combinaciones de solapamiento en data.table para unir los rangos de intervalo ( aquí hay un ejemplo ), que es una función separada de foverlaps() en este momento, y por lo tanto podría ser utilizada con los operadores de tubería (magrittr / pipeR? – never tried it mí mismo).

    Pero, en última instancia, nuestro objective es integrarlo en [.data.table para que podamos cosechar las otras características, como agrupar, agregar al unirnos, etc., que tendrá las mismas limitaciones descritas anteriormente.

  • Desde 1.9.4, data.table implementa indexación automática utilizando claves secundarias para subconjuntos basados ​​en búsqueda binaria rápida en syntax R regular. Ej: DT[x == 1] y DT[x %in% some_vals] crearán automáticamente un índice en la primera ejecución, que luego se usará en subconjuntos sucesivos desde la misma columna hasta el subconjunto rápido utilizando la búsqueda binaria. Esta característica continuará evolucionando. Verifique esta idea general para obtener una breve descripción de esta característica.

    Desde el modo en que se implementa filter() para data.tables, no aprovecha esta característica.

  • Una función dplyr es que también proporciona una interfaz para bases de datos que utilizan la misma syntax, que data.table no posee en este momento.

Por lo tanto, tendrá que sopesar estos (y probablemente otros puntos) y decidir en función de si estas concesiones son aceptables para usted.

HTH


(1) Tenga en cuenta que la eficiencia de la memoria impacta directamente en la velocidad (especialmente cuando los datos se agrandan), ya que el cuello de botella en la mayoría de los casos mueve los datos de la memoria principal a caché (y utiliza datos en caché tanto como sea posible). – para reducir el acceso a la memoria principal). No entrar en detalles aquí.

Solo inténtalo.

 library(rbenchmark) library(dplyr) library(data.table) benchmark( dplyr = diamondsDT %>% filter(cut != "Fair") %>% group_by(cut) %>% summarize(AvgPrice = mean(price), MedianPrice = as.numeric(median(price)), Count = n()) %>% arrange(desc(Count)), data.table = diamondsDT[cut != "Fair", list(AvgPrice = mean(price), MedianPrice = as.numeric(median(price)), Count = .N), by = cut][order(-Count)])[1:4] 

Sobre este problema parece que data.table es 2.4 veces más rápido que dplyr usando data.table:

  test replications elapsed relative 2 data.table 100 2.39 1.000 1 dplyr 100 5.77 2.414 

Revisado en base al comentario de Polymerase.

Para responder tu pregunta:

  • Sí, estás usando data.table
  • Pero no tan eficientemente como lo haría con la syntax pura de data.table

En muchos casos, este será un compromiso aceptable para aquellos que quieran la syntax de dplyr , aunque posiblemente sea más lento que dplyr con marcos de datos simples.

Un factor importante parece ser que dplyr copiará la data.table de data.table de forma predeterminada al agrupar. Considere (usando microbenchmark):

 Unit: microseconds expr min lq median diamondsDT[, mean(price), by = cut] 3395.753 4039.5700 4543.594 diamondsDT[cut != "Fair"] 12315.943 15460.1055 16383.738 diamondsDT %>% group_by(cut) %>% summarize(AvgPrice = mean(price)) 9210.670 11486.7530 12994.073 diamondsDT %>% filter(cut != "Fair") 13003.878 15897.5310 17032.609 

El filtrado es de velocidad comparable, pero la agrupación no lo es. Creo que el culpable es esta línea en dplyr:::grouped_dt :

 if (copy) { data <- data.table::copy(data) } 

donde la copy predeterminada es TRUE (y no se puede cambiar fácilmente a FALSE que puedo ver). Esto probablemente no represente el 100% de la diferencia, pero la sobrecarga general solo en algo del tamaño de los diamonds probablemente no sea la diferencia completa.

El problema es que para tener una gramática coherente, dplyr hace la agrupación en dos pasos. Primero establece claves en una copia de la tabla de datos original que coincide con los grupos, y solo después se agrupa. data.table solo asigna memoria para el grupo de resultados más grande, que en este caso es solo una fila, por lo que hace una gran diferencia en la cantidad de memoria que se debe asignar.

FYI, si a alguien le importa, encontré esto usando treeprof ( install_github("brodieg/treeprof") ), un visualizador arbóreo experimental (y aún muy alfa) para la salida Rprof :

enter image description here

Tenga en cuenta que lo anterior actualmente solo funciona en Mac AFAIK. Además, desafortunadamente, Rprof registra llamadas del tipo packagename::funname como anónimo, por lo que podría ser que todas y cada una de las datatable:: llamadas dentro de grouped_dt sean responsables, pero a partir de las pruebas rápidas parecía que datatable::copy es el gran uno.

Dicho esto, puede ver rápidamente cómo no hay demasiados gastos generales en la llamada a [.data.table , pero también hay una twig completamente separada para la agrupación.


EDITAR : para confirmar la copia:

 > tracemem(diamondsDT) [1] "<0x000000002747e348>" > diamondsDT %>% group_by(cut) %>% summarize(AvgPrice = mean(price)) tracemem[0x000000002747e348 -> 0x000000002a624bc0]:  grouped_dt group_by_.data.table group_by_ group_by  freduce _fseq eval eval withVisible %>% Source: local data table [5 x 2] cut AvgPrice 1 Fair 4358.758 2 Good 3928.864 3 Very Good 3981.760 4 Premium 4584.258 5 Ideal 3457.542 > diamondsDT[, mean(price), by = cut] cut V1 1: Ideal 3457.542 2: Premium 4584.258 3: Good 3928.864 4: Very Good 3981.760 5: Fair 4358.758 > untracemem(diamondsDT)