Doubletten ausschließen in R: unique() und wie man es schneller macht

data.table Logo

Eine Kundin erzählte mir kürzlich, dass sie die Base R-Funktion unique() nutzt, um Doubletten aus ihren Daten auszuschließen. Sie erhält damit das gewünschte Resultat, allerdings sei ihr Code zu langsam.

Zwei Ideen kamen mir, den Code zu beschleunigen:

1. Statt alle Spalten bei der Suche nach Doubletten zu berücksichtigen, müsste eine Auswahl an Spalten genügen – auch wenn die Daten über keine ID-Spalte verfügen.

2. Das data.table-Paket verfügt über eine eigene unique()-Funktion. Man muss ihr lediglich ein data.table-Objekt übergeben.

Erläuterung im Video: unique() und wie man es schneller macht

Wie schon im Beitrag über das iotools-Paket (Daten verarbeiten in Blöcken / Big-Data-Werkzeug), verwenden wir die flights-Daten aus dem nycflights13-Paket.

Pakete laden, Daten vorbereiten: flights

library(tidyverse)
library(bench)
library(nycflights13)
library(data.table)

data("flights")
flights_dt <- as.data.table(flights)
format(object.size(flights), units = "auto")
[1] "38.8 Mb"

Mit etwas über 300.000 Zeilen, 19 Variablen und einem Speicherbedarf von knapp unter 40 Mb handelt es sich zwar nicht um Big Data, doch die Daten sind genügend groß, um relevante Laufzeitunterschiede zwischen verschiedenen Ansätzen aufzuzeigen.

Hier arbeite ich mit zwei Datensätzen: Einer Base-R-Variante (data.frame, flights) und einem data.table-Objekt (flights_dt).

Erste Beschleunigungs-Strategie: Anzahl der Spalten begrenzen

Bisher (wie im oben erwähnten Beitrag über Datenverarbeitung in Blöcken mit iotools) habe ich für Benchmarks das microbenchmark-Paket verwendet. Hier nutzte ich die Gelegenheit, erstmals das neuere bench-Paket von Jim Hester (unter Mitarbeit von Drew Schmidt) auszuprobieren.

Der erste Beschleunigungsversuch sieht so aus:

# Run garbage collector before benchmarks
gc()

timings_base <- bench::mark(
   Base_full = unique(flights),
   Base_3Var = flights[-duplicated(flights[, c("dep_time", "arr_time", "time_hour")]), ],
   iterations = 1
 )

Um die Anzahl der Spalten, die für die Doubletten-Prüfung herangezogen werden, zu begrenzen, weiche ich auf die duplicated-Funktion aus. So erhalte ich immer noch den gesamten Datensatz zurück. Eine simple Begrenzung der Spalten, etwa über flights[, c(„dep_time“, „arr_time“, „time_hour“)], hätte einen Teildatensatz mit nur diesen Spalten geliefert.

bench bestimmt die Anzahl der Durchgänge automatisch anhand der Laufzeiten. Da es sich um relativ große Daten handelt, habe ich die Anzahl der Durchläufe auf 1 festgelegt (iterations = 1). Vor dem Geschwindigkeitsvergleich (Benchmark) habe ich explizit den Garbage Collector aufgerufen: Die „Müllabfuhr“, die Objekte aus dem Arbeitsspeicher löscht, die nicht mehr benötigt werden (d. h. auf die keine Speicherreferenz mehr verweist).

bench antwortet mit einer Fehlermeldung, für die ich sehr dankbar bin:

Fehler: Each result must equal the first result:
 Base_full does not equal Base_3Var

Im Gegensatz zu microbenchmark testet bench::mark() automatisch, ob die verglichenen Funktionen dasselbe Ergebnis zurückliefern! Ich hatte naiv angenommen, dass die drei Spalten Abflugzeit, Ankunftszeit und Zeitstempel (dep_time, arr_time, time_hour) genügen müssten, um jeden Flug eindeutig zu identifizieren. Das ist offenbar jedoch nicht der Fall!

Mit etwas Testen habe ich vier Spalten identifiziert, die für eine eindeutige Identifikation jedes Fluges genügen. Der zweite Versuch sieht so aus:

gc()

timings_base <- bench::mark(
   Base_full = unique(flights),
   Base_4Var = flights[!duplicated(flights[, c("tailnum", "minute", "time_hour", "flight")]), ],
   iterations = 1
)

timings_base

Je nach Rechner dauert es hier schon eine Weile, bis man Ergebnisse erhält. Auf meinem (relativ schwachen) Laptop bin ich deutlich im Sekunden-Bereich, wobei die zweite Variante mit nur vier berücksichtigten Variablen rund 2,5 mal schneller ist. Das kann bei noch größeren Daten bereits einen wesentlichen Unterschied ausmachen!

Bevor wir daran gehen, diese Zeit noch weit zu unterbieten, möchte ich etwas zu den beiden benchmarking-Paketen sagen, die ich in letzter Zeit verwendet habe.

Benchmarks bestimmen: bench::mark() vs. microbenchmark()

Zwei Gründe sorgen dafür, dass ich künftig das neuere bench dem älteren microbenchmark vorziehen werde:

  • Der automatische Test auf Ergebnis-Gleichheit
Warnmeldung:
 Some expressions had a GC in every iteration; so filtering is disabled. 
  • bench geht sehr explizit auf den garbage collector ein, der Messungen stark beeinflussen kann. Das gilt auch für grafische Darstellungen, bei denen Messpunkte farblich je nach garbage collector-Level unterschieden werden. Darauf verzichte ich hier, da aus Zeitgründen nur eine Messung pro Funktion vorgenommen wurde.

Zweite Beschleunigungs-Strategie: Umstieg auf data.table

Nun wollen wir testen, wie data.table::unique() im Vergleich abschneidet:

gc()
timings_dt <- bench::mark(
   dt_full = unique(flights_dt),
   dt_4Var = unique(flights_dt, by = c("tailnum", "minute", "time_hour", "flight")),
   iterations = 1
 )

timings_dt

Nun befinden wir uns im Millisekunden-Bereich. Die genaue Beschleunigung ermitteln wir gleich. Zunächst zur Syntax: Es genügt, der unique-Funktion ein data.table-Objekt zu übergeben (flights_dt, oben zugewiesen).

Im zweiten Fall, der Beschränkung auf ausgewählte Spalten, nutzen wir das by-Argument, das nur data.table’s unique-Funktion bietet.

Geschwindigkeits-Gewinne bei der Beseitigung von Doubletten

Um wie viel konnten wir die Doubletten-Beseitigung beschleunigen?

# Speed improvement by which factor?

# Full dataset
as.numeric(timings_base[1, "median"]) / as.numeric(timings_dt[1, "median"])
[1] 104

# Subset of columns
as.numeric(timings_base[2, "median"]) / as.numeric(timings_dt[2, "median"])
[1] 50

# Total speed improvement by combining data.table and using a subset of columns to Base R's unique on full data
as.numeric(timings_base[1, "median"]) / as.numeric(timings_dt[2, "median"])
[1] 120
  • unique() aus Base R und data.table, jeweils auf den ganzen Datensatz angewendet: data.table ist gut 100 mal schneller (Zahlen können auf anderen Geräten etwas abweichen, die generelle Aussage sollte sich jedoch nicht ändern);
  • unique() aus Base R und data.table, jeweils auf ausgewählte Spalten angewendet: data.table ist rund 50 mal schneller;
  • Gesamter Gewinn aus der Kombination beider Strategien, d. h. Base R’s unique() mit dem ganzen Datensatz vs. data.table’s unique() mit ausgewählten Spalten: data.table ist etwa 120 mal schneller.

In Produktiv-Workflows können solche Geschwindigkeitsgewinne einen massiven Unterschied ausmachen!

P. S. Aus Reaktionen auf das Video: Wer statt data.table lieber im tidyverse bleibt, kann auch dplyrs distinct() verwenden, das sich ebenfalls als weitaus schneller als Base R’s unique() erweist.

Viel Erfolg bei Euren R-Projekten!

Wie sind Eure Erfahrungen mit Laufzeiten von R-Code?

3 Gedanken zu „Doubletten ausschließen in R: unique() und wie man es schneller macht“

Freue mich über Kommentare!