Zwei der populärsten Pakete zur Datenaufbereitung in R sind data.table (Matt Dowle, Arun Srinivasan, viele Mitarbeiter) und dplyr (Hadley Wickham, viele Mitarbeiter). Während data.table zu Recht den Ruf hat, sehr schnell zu sein, hat dplyr vielen den Einstieg in R enorm erleichtert.
Geschwindigkeitsvergleiche: data.table vs. dplyr – beachte dtplyr!
Es gibt bereits seit Jahren eine Reihe von Benchmarks / Geschwindigkeitsvergleichen in Blogartikeln (z. B. hier, hier, hier, hier, hier oder hier) sowie etliche Einführungsvideos zu beiden Paketen.
Was mich heute zu einem weiteren Artikel motiviert: das dtplyr-Paket, das eine Brücke von dplyr zu data.table schlägt. Heißt konkret: Man kann das „Beste aus beiden Welten“ genießen – die von vielen als intuitiv empfundene dplyr-Syntax und die Geschwindigkeit von data.table. dtplyr übersetzt intern dplyr-Code in data.table-Code.
dtplyr wurde 2019 komplett umgeschrieben und ist seitdem wesentlich attraktiver, da leistungsfähiger. Der „Trick“ bestand darin, dplyr-Code nicht mehr Zeile für Zeile sofort zu übersetzen (eager evaluation), sondern einen Codeblock im ganzen anzunehmen und zu übersetzen, was deutliches Optimierungspotenzial mit sich bringt (lazy evaluation). Sichtbares Zeichen dafür ist die lazy_dt()-Funktion in den folgenden Codeblöcken.
TL; DR: Kurzfassung der wichtigsten Ergebnisse
Wem der Artikel zu lang ist: Es zeigt sich, dass man mit dtplyr sehr nahe an die data.table-Geschwindigkeit herankommt, auch wenn man selbst dplyr-Code schreibt. Entscheidend ist dabei, mit welchem Objekttyp man die Befehlskette beginnt: dtplyr ist sehr schnell, wenn man es mit einem data.table-Objekt füttert, und wesentlich langsamer, wenn man mit einem tibble (data.frame im tidyverse-Format) beginnt.
Inspiration: Iyar Lin
Die Inspiration zu diesem Beitrag stammt von Iyar Lins Artikel dtplyr speed benchmarks – vielen Dank an ihn (mehr von ihm auf seinem Github-Profil). Sein Beitrag wurde Ende Mai 2020 veröffentlicht, kurz bevor die neue dplyr-Version 1.0 auf CRAN zugänglich gemacht wurde. Ich habe Lins Code in mehrerlei Hinsicht umgeschrieben:
- microbenchmark() für die Geschwindigkeitsvergleiche statt system.time().
Nach meiner Erfahrung können Messungen von Codeabschnitten erheblich schwanken (z. B. abhängig von weiteren Betriebssystemprozessen, die im Hintergrund ablaufen). Lin verwendete zwei Messungen pro Funktion, ich übernehme microbenchmarks Voreinstellung von 10 Durchläufen, die Verteilungen zeigen. - Ein Skript statt Verteilung auf mehrere Skripte (den Code findet Ihr hier auf meinem Github-Profil)
- Definition eigener Funktionen statt Einbettung des gesamten Codes in den Benchmark-Aufruf
- Beschränkung auf zwei statt fünf Aufgaben; sie erlauben bereits hilfreiche Rückschlüsse
Verwendete R-Pakete
Folgende R-Pakete kamen zum Einsatz:
library(data.table) library(dplyr) library(dtplyr) library(microbenchmark) # Benchmarks library(ggplot2) # für die Autoplot-Funktion von microbenchmark
Datensimulation
Folgende Daten werden verwendet:
N <- 1e7 K <- 100 set.seed(1) DT <- data.table( id1 = sample(sprintf("id%03d", 1:K), N, TRUE), # large groups (char) id5 = sample(N / K, N, TRUE), # small groups (int) v1 = sample(5, N, TRUE), # int in range [1,5] v2 = sample(5, N, TRUE), # int in range [1,5] v3 = sample(round(runif(100, max = 100), 4), N, TRUE) # numeric, e. g. 23.5749 ) DF <- as_tibble(DT)
- 10 Millionen Zeilen
- zwei ID-Variablen: id1 mit 100 Gruppen, id2 mit 100.000
- drei numerische Variablen: v1 und v2 mit einem Wertebereich von 1 bis 5; v3 mit Zufallszahlen mit Dezimalstellen zwischen 0 und 100
- Zwei Varianten: DT als data.table-Objekt und DF als tibble (data.frame im tidyverse-Stil)
Bei den IDs und den Aufgaben habe ich die Nummerierung von Lins Artikel beibehalten, um die Zuordnung zu erleichtern, wenn jemand vergleichen möchte: Aufgaben 1 (q1) und 5 (q5).
Aufgaben: Nach IDs gruppieren, Summen berechnen
Folgende Aufgaben sollen von den Code-Alternativen gelöst werden:
- Nach id1 gruppieren, Summe von v1 berechnen: Ergebnis mit 100 Zeilen
- Nach id5 gruppieren, Summen von v1 bis v3 berechnen: Ergebnis mit 100.000 Zeilen
Codevarianten: data.table, dtplyr (2 Varianten), dplyr
Für die Geschwindigkeitsvergleiche (Benchmarks) gehen vier Code-Varianten an den Start:
- Eine reine data.table-Variante: DT_q1() und DT_q5(),
- Eine dtplyr-Variante, die mit einem tibble beginnt: dtplyr_q1() und dtplyr_q5(),
- Eine dtplyr-Variante, die mit einem data.table beginnt: dt_dtplyr_q1() und dt_dtplyr(q5),
- Eine reine dplyr-Variante: dplyr_q1() und dplyr_q5().
Hier der Code dazu – es werden zunächst „nur“ Funktionen definiert, die später elegant in den Benchmarks aufgerufen werden:
# data.table DT_q1 <- function() { DT[, sum(v1), keyby = id1] } DT_q5 <- function() { DT[, lapply(.SD, sum), keyby = id5, .SDcols = 3:5] }
# dtplyr: Beginn mit tibble dtplyr_q1 <- function() { DF %>% lazy_dt() %>% group_by(id1) %>% summarise(sum(v1)) %>% as_tibble() } dtplyr_q5 <- function() { DF %>% lazy_dt() %>% group_by(id5) %>% # summarise(across(v1:v3), sum) %>% # does not work yet, need old version summarise_at(vars(v1:v3), sum) %>% as_tibble() }
# dtplyr: Beginn mit data.table dt_dtplyr_q1 <- function() { DT %>% lazy_dt() %>% group_by(id1) %>% summarise(sum(v1)) %>% as.data.table() } dt_dtplyr_q5 <- function() { DT %>% lazy_dt() %>% group_by(id5) %>% # summarise(across(v1:v3), sum) %>% # does not work yet, need old version summarise_at(vars(v1:v3), sum) %>% as.data.table() }
# Reiner dplyr-Code dplyr_q1 <- function() { DF %>% group_by(id1) %>% summarise(sum(v1)) %>% as_tibble() } dplyr_q5 <- function() { DF %>% group_by(id5) %>% summarise_at(vars(v1:v3), sum) %>% # summarise(across(v1:v3), sum) %>% # extremely slow on my machine!! as_tibble() }
Benchmarking: microbenchmark()
Für die Geschwindigkeits-Vergleiche nutze ich das microbenchmark-Paket mit der gleichnamigen Funktion. Gegenüber der Base-R-Variante system.time() bietet es den Vorteil, auf einfache Weise (als Funktionsparameter) wiederholte Aufrufe durchzuführen. In der Praxis zeigen sich oft enorme Schwankungen zwischen den Messungen, mit zum Teil erheblichen Ausreißern. Man sollte sich also nie auf eine einzelne Messung verlassen, sondern lieber eine Verteilung von Messwerten betrachten.
Eine neuere Variante ist das bench-Paket aus dem RStudio-Umfeld (Jim Hester), das automatisiert testen kann, ob die Ergebnisse der zu vergleichenden Funktionen übereinstimmen. Darauf verzichte ich hier, da wir es mit unterschiedlichen Objekttypen zu tun haben: data.table vs. tibble (beides Erweiterungen / Varianten von data.frames). Stattdessen überprüfe ich selbst, ob die Funktionen dieselben Ergebnisse liefern.
# Funktionen testen str(DT_q1()) str(dtplyr_q1()) str(dt_dtplyr_q1()) str(dplyr_q1()) str(DT_q5()) str(dtplyr_q5()) str(dt_dtplyr_q5()) str(dplyr_q5())
Das Benchmarking ist nun sehr einfach:
q1 <- microbenchmark( DT_q1(), dtplyr_q1(), dt_dtplyr_q1(), dplyr_q1(), times = 10 ) q5 <- microbenchmark( DT_q5(), dtplyr_q5(), dt_dtplyr_q5(), dplyr_q5(), times = 10 )
Die Ergebnisse lassen sich sowohl tabellarisch als auch grafisch darstellen.
Ergebnisse: Grafisch und tabellarisch
Für q1 illustriert das Violinplot (Ergebnis der simplen autoplot()-Funktion) die Bandbreite der Ergebnisse; insbesondere die dtplyr-Varianten schwanken recht deutlich.
Betrachten wir die tabellarische Zusammenfassung, ergibt sich folgendes Bild:
- cld steht für compact letter display und stellt sehr kompakt Signifikanztests dar. Demnach sind data.table (DT_q1) und dtplyr mit einem data.table-Objekt (dt_dtplyr_q1) signifikant schneller (Gruppe a) als dplyr (dplyr_q1) und dtplyr mit einem tibble (dtplyr_q1) (Gruppe b).
- Um Ausreißern weniger Gewicht zu verleihen, bietet sich der Median als Entscheidungsgrundlage an. Demnach gewinnt (wenig überraschend) data.table, allerdings dicht gefolgt von der dtplyr-Variante, die auf ein data.table-Objekt angewendet wird. Die reine dplyr-Variante und dtplyr mit einem tibble sind deutlich abgeschlagen.
Hier noch das Boxplot, etwas unelegant als Base R-Diagramm; man beachte die logarithmische y-Achse:
Kommen wir zur zweiten Aufgabe (q5) mit 100.000 Ergebniszeilen:
Hier ist die reine dplyr-Variante weit abgeschlagen! Das bestätigt Befunde, die ich andernorts las – data.table hat insbesondere bei größeren Datenmengen Vorteile gegenüber dplyr. Ein wesentlicher Aspekt dabei dürfte die Speichereffizienz sein. R verwendet intern häufig sog. copy-on-modify-Operationen, d. h. dass Objekte kopiert und die Kopien bearbeitet werden. data.table hat Methoden implementiert, die dieses Verfahren umgehen und speicherintensive (und damit auch zeitaufwändige) Kopien vermeiden.
Der Signifikanztest bestätigt: Während die anderen drei Code-Varianten sich auf vergleichbarem Niveau bewegen (wobei dtplyr langsamer wird, wenn es mit einem tibble starten muss), benötigt dplyr mehr als fünf mal so lang für die gleiche Aufgabe.
Fazit: dtplyr für dplyr-Fans, die schnelleren Code benötigen
Das dtplyr-Paket erweist sich als wertvolle Unterstützung für diejenigen, die im tidyverse / dplyr-Stil schreiben wollen, aber bessere Performance benötigen. Wichtig: Die Befehlskette für dtplyr sollte mit einem data.table-Objekt beginnen!
Allerdings sollte man beachten: dtplyr wird sich schwerer tun mit Aufgaben, für die es keine genaue dplyr-Entsprechung gibt. Das gilt vor allem für cross joins und rolling joins. Siehe https://github.com/tidyverse/dtplyr
Zudem sollte man vorsichtig sein mit Generalisierungen. Je nach Aufgabe können die Vergleiche recht unterschiedlich ausfallen, wobei man mit dplyr kaum data.table schlagen wird.
Hier mein R-Code: https://github.com/fjodor/data.table_dplyr_dtplyr
In meinem Fortgeschrittenenkurs gibt es ein ganzes Kapitel zu Strategien, R-Code zu optimieren. Dabei geht es zunächst um sequentiellen R-Code (R nutzt in aller Regel nur einen Prozessorkern), um Strategien und Anwendungsmöglichkeiten, R-Code zu parallelisieren, sowie um einen Ausblick auf Pakete, um R mit großen Datenmengen zu nutzen, die die Grenzen des Arbeitsspeichers sprengen. Auch zu Strategien der Datenbankanbindung mit R kann ich Inhalte anbieten (z. B. Berechnungen in der Datenbank ausführen, Daten aus Datenbanken visualisieren, Paket-Empfehlungen).
Zu dplyr vergleiche den Beitrag R-Programmierung: Was ist %>% ? dplyr vs. Base R.
Literaturempfehlungen für effizienten R-Code:
Roger Peng: R Programming for Data Science
Mastering Parallel Programming with R
Roger Peng ist ausgewiesener Statistik- und R-Experte, betreibt unter anderem einen Podcast mit Hillary Parker und hat mehrere Bücher veröffentlicht. Das Buch kann man auch hier lesen.
Super Artikel. Ich habe rumgespielt und folgendes festgetsellt: Der Vergleich zwischen dtplyr für tibble vs. data.table-Objekte darf nicht außer Acht lassen, dass die Umwandlung in ein data.table-Objekt Zeit kostet. Daher habe ich den Code so angepasst, dass er folgende Dinge variiert: 1. Ob data.table, dtplyr, dplyr verwendet werden, 2. ob die Funktionen mit einem tibble oder data.table-Objekt gefüttert werden und 3. ob innerhalb der Funktion das Objekt zu einem data.table-Objekt umgewandelt wird oder nicht.
Es zeigt sich, dass alle drei Lösungen (data.table, dtplyr und dplyr) langsamer sind, wenn das Objekt erst in ein data.table-Objekt umgewandelt wird. Das heißt ganz praktisch: Es stimmt zwar, dass dtplyr schneller für data.table-Objekte ist, doch manchmal liegt das Objekt nunmal als tibble vor und muss daher erst in ein data.table-Objekt umgewandelt werden. Die Zeit zur Umwandlung selbst ist im Artikel nicht berücksichtigt. Es zeigt sich, dass der Zeitvorteil von data.table-Objekten bei dtplyr verschwindet, wenn man bedenkt, dass es Zeit braucht, das tibble erst in ein data.table-Objekt umzuwandeln. Anders gesagt: Liegt ein Objekt als tibble vor, kann man direkt dtplyr darauf anwenden – es bringt keinen Vorteil das Objekt erst in ein data.table-Objekt umzuwandeln. Der Code hierzu:
library(data.table)
library(dplyr)
library(dtplyr)
library(microbenchmark)
library(ggplot2)
N <- 1e7
K <- 100
set.seed(1)
dttbl <- data.table(
id1 = sample(sprintf("id%03d", 1:K), N, TRUE), # large groups (char)
id5 = sample(N / K, N, TRUE), # small groups (int)
v1 = sample(5, N, TRUE), # int in range [1,5]
v2 = sample(5, N, TRUE), # int in range [1,5]
v3 = sample(round(runif(100, max = 100), 4), N, TRUE) # numeric, e. g. 23.5749
)
tbbl <- as_tibble(dttbl)
# data.table method.
dt_fun <- function(data){
data[, lapply(.SD, sum), keyby = id5, .SDcols = 3:5]
}
# data.table method where the provided data is transformed
# to data.table first.
dt_trans_fun %
as.data.table() %>%
.[, lapply(.SD, sum), keyby = id5, .SDcols = 3:5]
}
# dtplyr method with lazy_dt.
dtplyr_fun %
lazy_dt() %>%
group_by(id5) %>%
summarise_at(vars(v1:v3), sum) %>%
as_tibble()
}
# dtplyr method with lazy_dt where the provided data is transformed
# to data.table first.
dtplyr_trans_fun %
as.data.table() %>%
lazy_dt() %>%
group_by(id5) %>%
summarise_at(vars(v1:v3), sum) %>%
as_tibble()
}
# dplyr method.
dplyr_fun %
group_by(id5) %>%
summarise_at(vars(v1:v3), sum) %>%
as_tibble()
}
dplyr_trans_fun %
as.data.table() %>%
group_by(id5) %>%
summarise_at(vars(v1:v3), sum) %>%
as_tibble()
}
results %
lapply(., function(object_i){
if(is.data.table(object_i)){
microbenchmark(
dt_fun(data= object_i), dt_trans_fun(data= object_i),
dtplyr_fun(data= object_i), dtplyr_trans_fun(data= object_i),
dplyr_fun(data= object_i), dplyr_trans_fun(data= object_i),
times= 25) %>%
{data.frame(method= .$expr, time= .$time, class= class(object_i)[1])} %>%
mutate(method= gsub(„data = object_i“, „“, method))
} else{
microbenchmark(
dt_trans_fun(data= object_i),
dtplyr_fun(data= object_i), dtplyr_trans_fun(data= object_i),
dplyr_fun(data= object_i), dplyr_trans_fun(data= object_i),
times= 25) %>%
{data.frame(method= .$expr, time= .$time, class= class(object_i)[1])} %>%
mutate(method= gsub(„data = object_i“, „“, method))
}
}) %>%
do.call(„rbind.data.frame“, .)
results %>%
mutate(method= factor(method, c(„dt_fun()“, „dt_trans_fun()“,
„dtplyr_fun()“, „dtplyr_trans_fun()“,
„dplyr_fun()“, „dplyr_trans_fun()“)),
class= factor(class, unique(class))) %>%
ggplot(., aes(time, method, fill= class)) +
geom_boxplot() +
guides(fill= guide_legend(reverse= TRUE)) +
theme_bw()
Vielen Dank!
Aus irgendeinem Grund wird der Code fehlerhaft angezeigt. Die Definition der Funktionen wird bspw. nicht richtig angezeigt. Aber ich denke der Gedanke wird nachvollziehbar. Danke nochmal.