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

91

Si uso la sintaxis de dplyr sobre una tabla de datos , ¿obtengo todos los beneficios de velocidad de la tabla de datos mientras sigo usando la sintaxis de dplyr? En otras palabras, ¿uso incorrectamente la tabla de datos si la consulto con la sintaxis dplyr? ¿O necesito usar una sintaxis de tabla de datos pura para aprovechar todo su poder?

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

library(data.table)
library(dplyr)

diamondsDT <- data.table(ggplot2::diamonds)
setkey(diamondsDT, cut) 

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 sintaxis dplyr detrás de escena:

diamondsDT [cut != "Fair"
        ] [, .(AvgPrice = mean(price),
                 MedianPrice = as.numeric(median(price)),
                 Count = .N), by=cut
        ] [ order(-Count) ]
Polimerasa
fuente
7
¿Por qué no usaría la sintaxis de la tabla de datos? Es elegante y eficiente también. La pregunta no tiene respuesta, ya que es muy amplia. Sí, existen dplyrmétodos para las tablas de datos, pero la tabla de datos también tiene sus propios métodos comparables
Rich Scriven
7
Puedo usar la sintaxis de tabla de datos o por supuesto. Pero de alguna manera, encuentro la sintaxis dplyr más elegante. Independientemente de mi preferencia por la sintaxis. Lo que realmente quiero saber es: ¿necesito usar una sintaxis de tabla de datos pura para obtener el 100% de los beneficios del poder de la tabla de datos?
Polimerasa
3
Para obtener un punto de referencia reciente donde dplyrse usa en data.framesy los correspondientes data.table, consulte aquí (y las referencias allí).
Henrik
2
@Polymerase - Creo que la respuesta a esa pregunta es definitivamente "Sí"
Rich Scriven
1
@Henrik: Más tarde me di cuenta de que había malinterpretado esa página porque solo mostraban el código para la construcción del marco de datos, pero no el código que usaban para la construcción de la tabla de datos. Cuando me di cuenta, borré mi comentario (esperando que no lo hubieras visto).
IRTFM

Respuestas:

77

No hay una respuesta simple / directa porque las filosofías de ambos paquetes difieren en ciertos aspectos. Por eso, algunos compromisos son inevitables. Estas son algunas de las inquietudes que quizás deba abordar / considerar.

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

Suponga DTcon, 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 la DTcolumna where a > 1. (2) devuelve mean(b)agrupados por c,dpara la misma expresión en ique (1).

Las dplyrexpresiones de uso común 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 de data.table son más cortos. Además, también son más eficientes en memoria 1 . ¿Por qué? Porque tanto en (3) como en (4), filter()devuelve filas para las 10 columnas primero, cuando en (3) solo necesitamos el número de filas, y en (4) solo necesitamos columnas b, c, dpara las operaciones sucesivas. Para superar esto, tenemos 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 mirar j-expression(desde la misma llamada de función) y darnos cuenta de que no hay necesidad de columnas en (1). La expresión en ise calcula y .Nes solo la suma de ese vector lógico que da el número de filas; el subconjunto completo nunca se realiza. En (2), solo las columnas b,c,dse materializan en el subconjunto, las demás 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 posterior filter()necesita todas esas columnas que filtramos. Deberá pensar en el futuro si desea realizar estas tareas de manera eficiente. Personalmente, lo encuentro contra-intuitivo en este caso.

Tenga en cuenta que en (5) y (6), todavía subconjuntamos columnas aque no requerimos. Pero no estoy seguro de cómo evitarlo. Si la filter()función tuviera un argumento para seleccionar las columnas a devolver, podríamos evitar este problema, pero entonces la función no hará una sola tarea (que también es una elección de diseño de 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 puede hacer:

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

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

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

Otras funcionalidades

  • En data.table, puede agregar mientras se une, y esto es más fácil de entender y es eficiente en la memoria, ya que el resultado de la unión intermedia nunca se materializa. Consulte esta publicación para ver un ejemplo. No puede (¿en este momento?) Hacer eso usando la sintaxis data.table / data.frame de dplyr.

  • La función de combinaciones sucesivas de data.table tampoco es compatible con la sintaxis de dplyr.

  • Recientemente implementamos uniones superpuestas en data.table para unir rangos de intervalo ( aquí hay un ejemplo ), que es una función separadafoverlaps() en este momento y, por lo tanto, podría usarse con los operadores de tubería (magrittr / pipeR? - nunca lo probé yo mismo).

    Pero en última instancia, nuestro objetivo es integrarlo [.data.tablepara que podamos recopilar las otras características como agrupar, agregar al unirse, etc., que tendrán las mismas limitaciones descritas anteriormente.

  • Desde 1.9.4, data.table implementa la indexación automática utilizando claves secundarias para subconjuntos basados ​​en búsquedas binarias rápidas en la sintaxis R normal. Por ejemplo: DT[x == 1]y DT[x %in% some_vals]creará automáticamente un índice en la primera ejecución, que luego se utilizará en subconjuntos sucesivos de la misma columna para un subconjunto rápido mediante la búsqueda binaria. Esta característica seguirá evolucionando. Consulte esta esencia para obtener una breve descripción general de esta función.

    Por la forma en que filter()se implementa para data.tables, no aprovecha esta característica.

  • Una característica de dplyr es que también proporciona una interfaz a las bases de datos que utilizan la misma sintaxis, que data.table no hace en este momento.

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

HTH


(1) Tenga en cuenta que la eficiencia de la memoria afecta directamente la velocidad (especialmente a medida que los datos aumentan de tamaño), ya que el cuello de botella en la mayoría de los casos es mover los datos de la memoria principal a la caché (y hacer uso de los datos en la caché tanto como sea posible, reducir las pérdidas de caché - para reducir el acceso a la memoria principal). Sin entrar en detalles aquí.

Arun
fuente
4
Absolutamente brillante. Gracias por eso
David Arenburg
6
Esa es una buena respuesta, pero sería posible (si no probable) que dplyr implemente un filter()plus eficiente summarise()usando el mismo enfoque que usa dplyr para SQL, es decir, crear una expresión y luego ejecutar solo una vez a pedido. Es poco probable que esto se implemente en un futuro cercano porque dplyr es lo suficientemente rápido para mí e implementar un planificador / optimizador de consultas es relativamente difícil.
hadley
Ser eficiente en la memoria también ayuda en otra área importante: completar la tarea antes de quedarse sin memoria. Al trabajar con grandes conjuntos de datos, me enfrenté a ese problema con dplyr y con pandas, mientras que data.table completaría el trabajo con elegancia.
Zaki
25

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]

En 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 según el comentario de Polymerase.

G. Grothendieck
fuente
2
Usando el microbenchmarkpaquete, descubrí que ejecutar el dplyrcódigo del OP en la versión original (marco de datos) de diamondstomó un tiempo promedio de 0.012 segundos, mientras que tomó un tiempo promedio de 0.024 segundos después de convertir diamondsa una tabla de datos. Ejecutar el data.tablecódigo de G. Grothendieck tomó 0.013 segundos. Al menos en mi sistema, parece dplyry data.tabletiene aproximadamente el mismo rendimiento. Pero, ¿por qué sería dplyrmás lento cuando el marco de datos se convierte por primera vez en una tabla de datos?
eipi10
Estimado G. Grothendieck, esto es maravilloso. Gracias por mostrarme esta utilidad de referencia. Por cierto, olvidó [order (-Count)] en la versión de la tabla de datos para hacer la equivalencia del arreglo de dplyr (desc (Count)). Después de agregar esto, la tabla de datos es aún más rápida en aproximadamente x1.8 (en lugar de 2.9).
Polimerasa
@ eipi10 ¿puedes volver a ejecutar tu banco con la versión de la tabla de datos aquí (agregada ordenación por desc Count en el último paso): diamondsDT [cut! = "Fair", list (AvgPrice = mean (price), MedianPrice = as.numeric (median (precio)), Count = .N), by = cut] [order (-Count)]
Polimerasa
Todavía 0.013 segundos. La operación de ordenar apenas lleva tiempo porque solo está reordenando la mesa final, que tiene solo cuatro filas.
eipi10
1
Hay algunos gastos generales fijos para la conversión de la sintaxis dplyr a la sintaxis de la tabla de datos, por lo que podría valer la pena probar diferentes tamaños de problemas. Además, es posible que no haya implementado el código de tabla de datos más eficiente en dplyr; los parches siempre son bienvenidos
hadley
22

Para responder tu pregunta:

  • Si, estas usando data.table
  • Pero no tan eficientemente como lo haría con data.tablesintaxis pura

En muchos casos, esto será un compromiso aceptable para aquellos que quieran la dplyrsintaxis, aunque posiblemente será más lento que dplyrcon los marcos de datos simples.

Un factor importante parece ser que dplyrse copiará data.tablede 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 tiene una 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 el valor copypredeterminado es TRUE(y no se puede cambiar fácilmente a FALSO que puedo ver). Es probable que esto no represente el 100% de la diferencia, pero la sobrecarga general por sí sola en algo del tamaño diamondsmás probable no es la diferencia total.

La cuestión es que para tener una gramática coherente, se dplyrhace la agrupación en dos pasos. Primero establece claves en una copia de la tabla de datos original que coinciden con los grupos, y solo más tarde agrupa. data.tablesolo 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.

Para su información, si a alguien le importa, encontré esto usando treeprof( install_github("brodieg/treeprof")), un visor de árbol experimental (y todavía muy alfa) para la Rprofsalida:

ingrese la descripción de la imagen aquí

Tenga en cuenta que lo anterior actualmente solo funciona en macs AFAIK. Además, desafortunadamente, Rprofregistra las llamadas del tipo packagename::funnamecomo anónimas, por lo que en realidad podrían ser todas y cada una de las datatable::llamadas internas grouped_dtlas responsables, pero de las pruebas rápidas parecía que era datatable::copyla más grande.

Dicho esto, puede ver rápidamente cómo no hay tanta sobrecarga alrededor de la [.data.tablellamada, pero también hay una rama 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]: <Anonymous> grouped_dt group_by_.data.table group_by_ group_by <Anonymous> 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)
BrodieG
fuente
Esto es genial, gracias. ¿Eso significa que dplyr :: group_by () duplicará el requisito de memoria (en comparación con la sintaxis de tabla de datos pura) debido al paso de copia de datos internos? Es decir, si el tamaño de mi objeto de tabla de datos es de 1 GB, y uso la sintaxis encadenada dplyr similar a la de la publicación original. ¿Necesitaré al menos 2 GB de memoria libre para obtener los resultados?
Polimerasa
2
Siento que lo arreglé en la versión de desarrollo.
hadley
@hadley, estaba trabajando desde la versión CRAN. Mirando al desarrollador, parece que resolvió parcialmente el problema, pero la copia real permanece (no se ha probado, solo mira las líneas c (20, 30:32) en R / grouped-dt.r. Probablemente sea más rápido ahora, pero Apuesto a que el paso lento es la copia.
BrodieG
3
También estoy esperando una función de copia superficial en data.table; hasta entonces creo que es mejor ser seguro que rápido.
hadley
2

Puede usar dtplyr ahora, que es parte de tidyverse . Le permite usar declaraciones de estilo dplyr como de costumbre, pero utiliza una evaluación perezosa y traduce sus declaraciones a código data.table bajo el capó. La sobrecarga en la traducción es mínima, pero usted obtiene todos, si no, la mayoría de los beneficios de data.table. Más detalles en el repositorio oficial de git aquí y en la página de tidyverse .

Leche negra
fuente