2024-10-09
Open-Government-Daten mit duckplyr analysieren
Hannes Mühleisen
Zur duckplyr-Dokumentation:
duckplyr.tidyverse.org.
Daten zu wranglen, indem man SQL-Strings darauf wirft, ist nicht der ergonomischste Weg, interaktive Datenanalyse in R zu machen. Seit einer Weile arbeiten wir mit dem dplyr-Projektteam bei Posit (früher RStudio) und Kirill Müller an duckplyr. duckplyr ist ein hochperformanter Drop-in-Ersatz für dplyr, angetrieben von DuckDB. Mehr über duckplyr steht im Ankündigungs-Blogpost. In diesem Beitrag gehen wir einen anspruchsvollen realen Einsatzfall mit duckplyr durch. Wer mitmachen möchte, findet ein Google-Colab-Notebook mit allen Code-Snippets in diesem Beitrag. Die unten berichteten Zeiten stammen ebenfalls aus Colab.
Wie viele amtliche Statistikbehörden stellt Neuseelands „Stats NZ Tatauranga Aotearoa“ dankenswerterweise einige der von ihnen gepflegten Datensätze als Open Data zum Download bereit. Die größte auf der Seite verfügbare Datei enthält „Age and sex by ethnic group (grouped total responses), for census usually resident population counts, 2006, 2013, and 2018 Censuses“, CSV-Zip-Datei.
Wir können diese Datei herunterladen (gespiegelt von unserem CDN, wir wollen arme Stats NZ nicht DDoS-en) und so entpacken:
download.file("https://blobs.duckdb.org/nzcensus.zip", "nzcensus.zip")unzip("nzcensus.zip")Schauen wir uns die CSV-Dateien im Zip und ihre Größen an:
file.info(Sys.glob("*.csv"))["size"] sizeData8277.csv 857672667DimenLookupAge8277.csv 2720DimenLookupArea8277.csv 65400DimenLookupEthnic8277.csv 272DimenLookupSex8277.csv 74DimenLookupYear8277.csv 67Wie wir sehen, gibt es eine große (~800 MB) Data-Datei und eine Reihe von Dimen...-Dimensionsdateien. Das ist ein recht übliches Datenlayout, manchmal „Star Schema“ genannt. Daraus ist klar, dass in unserer Zukunft ein paar Joins stehen. Aber zuerst konzentrieren wir uns auf die Hauptdatei, Data8277.csv. Das Lesen großer CSV-Dateien ist nicht trivial und kann sehr frustrierend sein. Aber genug gejammert, wie die Kiwis sagen würden.
Lassen Sie uns zunächst kurz schauen, wie die Datei aussieht:
cat(paste(readLines("Data8277.csv", n=10), collapse="\n"))Year,Age,Ethnic,Sex,Area,count2018,000,1,1,01,7952018,000,1,1,02,50672018,000,1,1,03,22292018,000,1,1,04,13562018,000,1,1,05,1802018,000,1,1,06,7382018,000,1,1,07,6302018,000,1,1,08,11882018,000,1,1,09,2157Bisher sieht das eher zahm aus, es scheinen fünf Spalten zu sein. Dankenswerterweise haben sie Namen. Beim bloßen Anschauen der Spaltenwerte sieht es so aus, als wären sie alle numerisch und sogar Integer-Werte. Der Schein kann aber trügen, und die Spalten Age, Area, count enthalten irgendwo weiter unten Zeichenwerte. Fun Fact: Wir müssen bis Zeile 431 741 warten, bis die Spalte Area einen Nicht-Integer-Wert enthält. Offensichtlich brauchen wir einen guten CSV-Parser. R hat keinen Mangel an CSV-Readern, zum Beispiel enthält das Paket readr einen flexiblen CSV-Parser. Diese Datei mit readr zu lesen dauert etwa eine Minute (auf Colab).
Aber jetzt fangen wir an, DuckDB und duckplyr zu nutzen. Zuerst installieren wir duckplyr (und DuckDB, das eine Abhängigkeit ist):
install.packages("duckplyr")duckdb:::sql("SELECT version()")Dieser Befehl gibt die installierte DuckDB-Version aus; zum Zeitpunkt dieses Schreibens ist die neueste Version auf CRAN 1.1.0. Wir können jetzt DuckDBs fortgeschrittene Data-Wrangling-Fähigkeiten nutzen. Zuerst enthält DuckDB wahrscheinlich den weltweit fortschrittlichsten CSV-Parser. Für die Extra-Neugierigen: Hier ist eine Präsentation zu DuckDBs CSV-Parser. Wir nutzen DuckDBs CSV-Reader, um nur die ersten 10 Zeilen aus der CSV-Datei zu lesen:
duckdb:::sql("FROM Data8277.csv LIMIT 10") Year Age Ethnic Sex Area count1 2018 000 1 1 01 7952 2018 000 1 1 02 50673 2018 000 1 1 03 22294 2018 000 1 1 04 13565 2018 000 1 1 05 1806 2018 000 1 1 06 7387 2018 000 1 1 07 6308 2018 000 1 1 08 11889 2018 000 1 1 09 215710 2018 000 1 1 12 177Das dauert nur ein paar Millisekunden, weil DuckDBs CSV-Reader Ergebnisse streaming erzeugt, und weil wir nur 10 Zeilen angefordert haben, sind wir ziemlich schnell fertig.
DuckDB kann mit dem Schlüsselwort DESCRIBE auch das Schema ausgeben, das es aus der CSV-Datei erkannt hat:
duckdb:::sql("DESCRIBE FROM Data8277.csv") column_name column_type ...1 Year BIGINT ...2 Age VARCHAR ...3 Ethnic BIGINT ...4 Sex BIGINT ...5 Area VARCHAR ...6 count VARCHAR ...Wir sehen, dass wir die verschiedenen Datentypen für die Spalten korrekt erkannt haben. Wir können das Schlüsselwort SUMMARIZE nutzen, um verschiedene Zusammenfassungsstatistiken für alle Spalten in der Datei zu berechnen:
duckdb:::sql("SUMMARIZE FROM Data8277.csv")Das dauert etwas länger, aber die Ergebnisse sind sehr interessant:
# A tibble: 6 × 12 column_name column_type min max approx_unique avg std q25 q50 <chr> <chr> <chr> <chr> <dbl> <chr> <chr> <chr> <chr>1 Year BIGINT 2006 2018 3 2012.33… 4.92… 2006 20132 Age VARCHAR 000 999999 149 NA NA NA NA3 Ethnic BIGINT 1 9999 11 930.545… 2867… 3 64 Sex BIGINT 1 9 3 4.0 3.55… 1 25 Area VARCHAR 001 DHB9999 2048 NA NA NA NA6 count VARCHAR ..C 9999 16825 NA NA NA NA# ℹ 3 more variables: q75 <chr>, count <dbl>, null_percentage <dbl>Das zeigt wieder die Spaltennamen und ihre Typen, aber auch die Zusammenfassungsstatistiken für Minimum und Maximum, ungefähre Zahl eindeutiger Werte, Durchschnitt, Standardabweichungen, 25-, 50- und 75-Quantile und den Prozentsatz von NULL/NA-Werten. Man bekommt also einen ziemlich guten Überblick, wie die Daten aussehen.
Aber wir sind nicht hier, um Zusammenfassungsstatistiken anzustarren, wir wollen eine echte Analyse der Daten machen. In diesem Einsatzfall wollen wir die Zahl der Nicht-Europäer zwischen 20 und 40 berechnen, die im Gebiet Auckland leben, anhand der Census-Daten 2018, und die Ergebnisse sollen nach Geschlecht gruppiert sein. Dafür müssen wir die Dimensions-CSV-Dateien mit der Hauptdatendatei joinen, um die Dimensionswerte richtig zu filtern. In SQL, der Lingua Franca der großskaligen Datenanalyse, sieht das so aus:
Zuerst joinen wir alles zusammen:
FROM 'Data8277.csv' dataJOIN 'DimenLookupAge8277.csv' age ON data.Age = age.CodeJOIN 'DimenLookupArea8277.csv' area ON data.Area = area.CodeJOIN 'DimenLookupEthnic8277.csv' ethnic ON data.Ethnic = ethnic.CodeJOIN 'DimenLookupSex8277.csv' sex ON data.Sex = sex.CodeJOIN 'DimenLookupYear8277.csv' year ON data.Year = year.CodeAls Nächstes nutzen wir die SELECT-Projektion, um ein paar grundlegende Umbenennungen und Datenbereinigungen durchzuführen:
SELECT year.Description AS year_, area.Description AS area_, ethnic.Description AS ethnic_, sex.Description AS sex_, TRY_CAST(replace(age.Description, ' years', '') AS INTEGER) AS age_, TRY_CAST(data.count AS INTEGER) AS count_Der Datensatz enthält verschiedene Totals, die wir entfernen, bevor wir weitermachen:
WHERE count_ > 0 AND age_ IS NOT NULL AND area_ NOT LIKE 'Total%' AND ethnic_ NOT LIKE 'Total%' AND sex_ NOT LIKE 'Total%'Wir packen die vorherigen Anweisungen als Common-Table-Expression expanded_cleaned_data und können dann die eigentliche Aggregation mit DuckDB berechnen:
SELECT sex_, sum(count_) AS group_countFROM expanded_cleaned_dataWHERE age_ BETWEEN 20 AND 40 AND area_ LIKE 'Auckland%' AND ethnic_ <> 'European' AND year_ = 2018GROUP BY sex_ORDER BY sex_Das dauert ca. 20 s auf dem begrenzten freien Colab-Tier. Das Ergebnis ist:
sex_ group_count1 Female 3985562 Male 397326Soweit, so gut. SQL-Abfragen zu schreiben ist aber nicht für jeden. Die Ergonomie, SQL-Strings in einer interaktiven Datenanalyseumgebung wie R zu erzeugen, ist gelinde gesagt fragwürdig. Frameworks wie dplyr haben gezeigt, wie Data-Wrangling-Ergonomie massiv verbessert werden kann. Drücken wir unsere Analyse also mit dplyr aus, nachdem wir die Daten zuerst aus CSV in den RAM gelesen haben:
library(dplyr)
data <- readr::read_csv("Data8277.csv")age <- readr::read_csv("DimenLookupAge8277.csv")area <- readr::read_csv("DimenLookupArea8277.csv")ethnic <- readr::read_csv("DimenLookupEthnic8277.csv")sex <- readr::read_csv("DimenLookupSex8277.csv")year <- readr::read_csv("DimenLookupYear8277.csv")
expanded_cleaned_data <- data |> filter(grepl("^\\d+$", count)) |> mutate(count_ = as.integer(count)) |> filter(count_ > 0) |> inner_join( age |> filter(grepl("^\\d+ years$", Description)) |> mutate(age_ = as.integer(Code)), join_by(Age == Code) ) |> inner_join(area |> mutate(area_ = Description) |> filter(!grepl("^Total", area_)), join_by(Area == Code)) |> inner_join(ethnic |> mutate(ethnic_ = Description) |> filter(!grepl("^Total", ethnic_)), join_by(Ethnic == Code)) |> inner_join(sex |> mutate(sex_ = Description) |> filter(!grepl("^Total", sex_)), join_by(Sex == Code)) |> inner_join(year |> mutate(year_ = Description), join_by(Year == Code))
# create final aggregation, still completely lazilytwenty_till_forty_non_european_in_auckland_area <- expanded_cleaned_data |> filter( age_ >= 20, age_ <= 40, grepl("^Auckland", area_), year_ == "2018", ethnic_ != "European" ) |> summarise(group_count = sum(count_), .by = sex_) |> arrange(sex_)
print(twenty_till_forty_non_european_in_auckland_area)Das sieht schöner aus und ist in ca. einer Minute fertig, hat aber mehrere versteckte Probleme. Erstens lesen wir den gesamten Datensatz in den RAM. Für diesen Datensatz ist das wahrscheinlich möglich, weil die meisten Computer mehr als 1 GB RAM haben, für größere Datensätze funktioniert das natürlich nicht. Dann führen wir eine Reihe von dplyr-Verbs aus. dplyr führt diese aber eagerly aus, das heißt es optimiert die Folge von Verbs nicht ganzheitlich. Es kann zum Beispiel nicht sehen, dass wir im letzten Schritt alle nicht-europäischen Ethnizitäten herausfiltern, und berechnet all das trotzdem für das Zwischenergebnis. Dasselbe passiert mit Survey-Jahren, die nicht 2018 sind; erst im letzten Schritt filtern wir die heraus. Wir haben einen teuren Join über alle anderen Jahre umsonst berechnet. Je nach Datenverteilung kann das extrem verschwenderisch sein. Und ja, man kann die Filter manuell herumschieben, das ist aber mühsam und fehleranfällig. Wenigstens ist das Ergebnis genau dasselbe wie die SQL-Version oben:
# A tibble: 2 × 2 sex_ group_count <chr> <int>1 Female 3985562 Male 397326Jetzt stellen wir dasselbe Skript auf duckplyr um. Statt die CSV-Dateien vollständig mit readr in den RAM zu lesen, nutzen wir die Funktion duckplyr_df_from_csv aus duckplyr:
library("duckplyr")
data <- duckplyr_df_from_csv("Data8277.csv")age <- duckplyr_df_from_csv("DimenLookupAge8277.csv")area <- duckplyr_df_from_csv("DimenLookupArea8277.csv")ethnic <- duckplyr_df_from_csv("DimenLookupEthnic8277.csv")sex <- duckplyr_df_from_csv("DimenLookupSex8277.csv")year <- duckplyr_df_from_csv("DimenLookupYear8277.csv")Das dauert genau 0 Sekunden, weil duckplyr eigentlich nicht viel tut. Wir erkennen das Schema der CSV-Dateien mit unserem preisgekrönten „Sniffer“ und legen die sechs Placeholder-Objekte für jede dieser Dateien an. Teil des einzigartigen Designs von duckplyr ist, dass diese Objekte „Heisenbergsche“ sind: Sie verhalten sich wie völlig normale R-data.frames, sobald sie als solche behandelt werden, können aber auch als Lazy-Evaluation-Placeholder wirken, wenn sie an nachgelagerte Analyseschritte übergeben werden. Das wird durch ein wenig bekanntes R-Feature namens ALTREP möglich, das unter anderem erlaubt, R-Vektoren on-demand zu berechnen.
Jetzt führen wir dieselbe dplyr-Pipeline wie oben erneut aus. Nur diesmal sind wir in weniger als einer Sekunde „fertig“. Das liegt daran, dass wir nur lazy einen sogenannten Relation Tree konstruiert haben, der die Gesamtheit der Transformationen kapselt. Das erlaubt ganzheitliche Optimierung, zum Beispiel das Pushen von Jahr und Ethnizität ganz nach unten zum Lesen der CSV-Datei vor dem Joinen. Wir können auch das Lesen von Spalten eliminieren, die in der Abfrage gar nicht genutzt werden.
Erst wenn wir schließlich das Ergebnis ausgeben
print(twenty_till_forty_non_european_in_auckland_area)wird die eigentliche Berechnung ausgelöst. Das ist in derselben Zeit fertig wie die handgeschriebene SQL-Abfrage oben, nur dass wir diesmal eine deutlich angenehmere Erfahrung durch die dplyr-Syntax hatten. Und dankenswerterweise ist das Ergebnis immer noch genau dasselbe.
Dieser Einsatzfall wurde auch als Teil meiner Keynote auf der diesjährigen posit::conf vorgestellt:
Schließlich sollten wir anmerken, dass duckplyr noch in Entwicklung ist. Wir haben große Sorgfalt darauf verwendet, nichts zu kaputtzumachen, und fallen auf die bestehende dplyr-Implementierung zurück, wenn etwas (noch) nicht in DuckDB laufen kann. Wir würden uns aber freuen, von Ihnen zu hören, wenn etwas nicht wie erwartet funktioniert.