Tidy Data

Data Wrangling
Datenqualität
R
Python
Eine Zeile je Beobachtung, eine Spalte je Variable, und warum das trägt.

Kernideen

  • Eine Zeile je Beobachtung, eine Spalte je Merkmal, ein Wert je Zelle.
  • Steht in einem Spaltennamen eine Information, die ein Wert ist (ein Jahr, ein Monat, ein Gerätename), ist die Tabelle breit und die Auswertung will sie lang.
  • Langes Format zum Rechnen, Filtern und Zeichnen, breites Format für die Anzeige. Die Umformung ins Breite ist der letzte Schritt, nicht der erste.
  • Mehrere Werte in einer Zelle werden aufgeteilt, bevor gerechnet wird.
  • Beschreibt eine Tabelle zwei Beobachtungseinheiten, gehört sie getrennt. Wiederholte Angaben widersprechen sich sonst irgendwann.

Erklärung

Vorwissen: R-Grundlagen oder pandas für den Data Frame. Wie man Daten in diese Form bringt, steht unter Data Wrangling, woher sie kommen unter Datenimport, und was man vor dem Umformen prüft unter Datenqualität.

Warum die Aufbereitung den grössten Teil der Zeit frisst

Daten kommen so, wie sie irgendwo entstanden sind, und nicht so, wie eine Auswertung sie braucht. Ein Export aus einer Maschinensteuerung hat Datumsangaben als Text, Messwerte mit Dezimalkomma, Kategorien mit Tippfehlern und irgendwo einen Strich für einen fehlenden Wert. Nichts davon ist ein Fehler; es ist der Normalzustand.

Das Werkzeug dagegen erwartet eine bestimmte Form. Wird sie hergestellt, läuft alles Weitere glatt. Wird sie nicht hergestellt, kostet jeder Schritt Sonderbehandlung, und diese Sonderbehandlungen summieren sich zu genau dem Aufwand, der in Projektplänen fehlt.

Die drei Regeln

Regel verletzt, wenn Beispiel
Eine Spalte je Merkmal ein Wert im Spaltennamen steht jahr_2024, jahr_2025
Eine Zeile je Beobachtung mehrere Werte in einer Zelle stehen 12,4/13,1
Eine Tabelle je Beobachtungseinheit zwei Einheiten gemischt sind Messungen und Standortstammdaten in einer Tabelle

Lang und breit

langes Format breites Format
Aufbau standort, jahr, menge standort, 2024, 2025
Zeilen eine je Messung eine je Standort
gut für filtern, gruppieren, zeichnen, modellieren lesen und vergleichen im Bericht
Umformung pivot_longer() / melt() pivot_wider() / pivot()

Der Grund für die Vorliebe der Werkzeuge ist einfach: Im langen Format ist jede Grösse ansprechbar. Ein Filter auf das Jahr ist eine Bedingung auf einer Spalte, im breiten Format wäre es eine Auswahl von Spaltennamen. Eine Grafik nach Jahr einzufärben braucht eine Spalte jahr; im breiten Format gibt es sie nicht.

Kurz nachgeschlagen

Aufgabe R (tidyr) pandas
breit zu lang pivot_longer(cols, names_to, values_to) melt(id_vars, var_name, value_name)
lang zu breit pivot_wider(names_from, values_from) pivot(index, columns, values)
mit Aggregation ins Breite pivot_wider(values_fn = mean) pivot_table(aggfunc="mean")
Zelle mit Liste aufteilen separate_rows(spalte, sep = "/") .str.split("/") und .explode()
Spalte in zwei teilen separate_wider_delim() .str.split(expand=True)
Eindeutigkeit prüfen n_distinct() je Gruppe .groupby(...).nunique()
Stammdaten herausziehen distinct(a, b) .drop_duplicates()

Beispiele

Frage und Datenlage

Mengen für drei Standorte in zwei Jahren, abgelegt mit einer Spalte je Jahr (Bern 120 und 131, Chur 95 und 92, Sion 143 und 150). Gesucht sind das Mittel je Jahr und die Veränderung je Standort in Prozent.

Rechnung

breit <- data.frame(standort = c("Bern", "Chur", "Sion"),
                    jahr_2024 = c(120, 95, 143),
                    jahr_2025 = c(131, 92, 150))
breit
  standort jahr_2024 jahr_2025
1     Bern       120       131
2     Chur        95        92
3     Sion       143       150
lang <- pivot_longer(breit, cols = starts_with("jahr_"),
                     names_to = "jahr", values_to = "menge",
                     names_prefix = "jahr_")
lang
# A tibble: 6 × 3
  standort jahr  menge
  <chr>    <chr> <dbl>
1 Bern     2024    120
2 Bern     2025    131
3 Chur     2024     95
4 Chur     2025     92
5 Sion     2024    143
6 Sion     2025    150
dim(lang)
[1] 6 3
lang |> group_by(jahr) |> summarise(mittel = round(mean(menge), 2))
# A tibble: 2 × 2
  jahr  mittel
  <chr>  <dbl>
1 2024    119.
2 2025    124.
breit |>
  mutate(wachstum = round((jahr_2025 - jahr_2024) / jahr_2024 * 100, 2)) |>
  select(standort, wachstum)
  standort wachstum
1     Bern     9.17
2     Chur    -3.16
3     Sion     4.90
breit = pd.DataFrame({"standort": ["Bern", "Chur", "Sion"],
                      "jahr_2024": [120, 95, 143],
                      "jahr_2025": [131, 92, 150]})
print(breit.to_string(index=False))
standort  jahr_2024  jahr_2025
    Bern        120        131
    Chur         95         92
    Sion        143        150
lang = breit.melt(id_vars="standort", var_name="jahr", value_name="menge")
lang["jahr"] = lang["jahr"].str.removeprefix("jahr_").astype(int)
print(lang.to_string(index=False))
standort  jahr  menge
    Bern  2024    120
    Chur  2024     95
    Sion  2024    143
    Bern  2025    131
    Chur  2025     92
    Sion  2025    150
print(lang.shape)
(6, 3)
print(lang.groupby("jahr")["menge"].mean().round(2).to_dict())
{2024: 119.33, 2025: 124.33}
w = lang.pivot(index="standort", columns="jahr", values="menge")
print(((w[2025] - w[2024]) / w[2024] * 100).round(2).to_dict())
{'Bern': 9.17, 'Chur': -3.16, 'Sion': 4.9}

Output Zeile für Zeile

Grösse Wert Erklärung
breite Tabelle 3 Zeilen, 3 Spalten Eine Zeile je Standort, das Jahr steckt im Spaltennamen.
lange Tabelle 6 Zeilen, 3 Spalten Eine Zeile je Messung. Aus zwei Wertespalten werden zwei Spalten jahr und menge.
Mittel 2024 119.33 (120 + 95 + 143) / 3
Mittel 2025 124.33 (131 + 92 + 150) / 3
Wachstum Bern +9.17 Prozent von 120 auf 131
Wachstum Chur −3.16 Prozent von 95 auf 92, der einzige Rückgang
Wachstum Sion +4.90 Prozent von 143 auf 150

Die beiden Auswertungen zeigen, wofür welche Form taugt. Das Mittel je Jahr ist im langen Format ein group_by(jahr), im breiten müsste man zwei Spalten einzeln mitteln und die Ergebnisse zusammensetzen. Die Veränderung dagegen vergleicht zwei Jahre in einer Zeile und ist deshalb im breiten Format einfacher. Beide Formen haben ihren Platz, man muss nur wechseln können.

Interpretation und Ergebnissatz

Im langen Format ist das Jahr ein Merkmal wie jedes andere. Alles, was gruppiert, filtert oder zeichnet, arbeitet damit ohne Sonderfall; Werkzeuge wie ggplot2 und seaborn setzen es voraus.

Aus der breiten Tabelle mit drei Zeilen wird im langen Format eine mit sechs Zeilen. Im Mittel stieg die Menge von 119.33 auf 124.33; nur Chur ging mit −3.16 Prozent zurück, während Bern um 9.17 Prozent zulegte.

Frage und Datenlage

In einer Exporttabelle stehen alle Messungen eines Standorts in einer Zelle, durch Schrägstrich getrennt: Bern 12,4/13,1, Chur 9,8. Wie wird daraus eine Zeile je Messung, und was ist die Summe?

Rechnung

roh <- data.frame(standort = c("Bern", "Chur"),
                  messungen = c("12,4/13,1", "9,8"))
roh
  standort messungen
1     Bern 12,4/13,1
2     Chur       9,8
geteilt <- roh |>
  separate_rows(messungen, sep = "/") |>
  mutate(messung = as.numeric(sub(",", ".", messungen, fixed = TRUE))) |>
  select(standort, messung)
geteilt
# A tibble: 3 × 2
  standort messung
  <chr>      <dbl>
1 Bern        12.4
2 Bern        13.1
3 Chur         9.8
c(zeilen = nrow(geteilt), summe = sum(geteilt$messung))
zeilen  summe 
   3.0   35.3 
roh = pd.DataFrame({"standort": ["Bern", "Chur"],
                    "messungen": ["12,4/13,1", "9,8"]})
print(roh.to_string(index=False))
standort messungen
    Bern 12,4/13,1
    Chur       9,8
geteilt = roh.assign(messung=roh["messungen"].str.split("/")).explode("messung")
geteilt["messung"] = geteilt["messung"].str.replace(",", ".").astype(float)
print(geteilt[["standort", "messung"]].to_string(index=False))
standort  messung
    Bern     12.4
    Bern     13.1
    Chur      9.8
print(len(geteilt), geteilt["messung"].sum().round(2))
3 35.3

Output Zeile für Zeile

Schritt Ergebnis Erklärung
Rohtabelle 2 Zeilen Bern trägt zwei Messungen in einer Zelle.
aufgeteilt 3 Zeilen: Bern 12.4, Bern 13.1, Chur 9.8 separate_rows beziehungsweise .str.split() mit .explode(). Der Standort wird dabei wiederholt.
umgewandelt Zahlen statt Text Das Dezimalkomma muss ersetzt werden, sonst bleibt es Text.
Summe 35.3 12.4 + 13.1 + 9.8. Auf der Rohtabelle wäre sie gar nicht zu bilden.

Interpretation und Ergebnissatz

Solche Zellen entstehen, wenn ein Export für das Auge gemacht wurde. Solange sie bestehen, ist jede Kennzahl blockiert: Man kann weder zählen noch summieren noch gruppieren. Das Aufteilen ist deshalb kein Schönheitsschritt, sondern die Voraussetzung für alles Weitere. Die Zeilenzahl steigt dabei, und Werte, die vorher einmal dastanden (hier der Standort), wiederholen sich.

Aus zwei Zeilen mit zusammengefassten Messungen werden drei Zeilen mit je einem Wert; erst danach lässt sich die Summe von 35.3 bilden.

Frage und Datenlage

Eine Tabelle führt je Standort und Jahr die Menge, dazu in jeder Zeile den Kanton. Der Kanton gehört aber zum Standort, nicht zur Messung. In der zweiten Chur-Zeile steht GH statt GR. Wie fällt das auf?

Rechnung

gemischt <- data.frame(
  standort = c("Bern", "Bern", "Chur", "Chur"),
  kanton   = c("BE", "BE", "GR", "GH"),
  jahr     = c(2024, 2025, 2024, 2025),
  menge    = c(120, 131, 95, 92))
gemischt
  standort kanton jahr menge
1     Bern     BE 2024   120
2     Bern     BE 2025   131
3     Chur     GR 2024    95
4     Chur     GH 2025    92
gemischt |> group_by(standort) |> summarise(kantone = n_distinct(kanton))
# A tibble: 2 × 2
  standort kantone
  <chr>      <int>
1 Bern           1
2 Chur           2
orte <- distinct(gemischt, standort, kanton)
orte
  standort kanton
1     Bern     BE
2     Chur     GR
3     Chur     GH
nrow(orte)
[1] 3
gemischt = pd.DataFrame({
    "standort": ["Bern", "Bern", "Chur", "Chur"],
    "kanton": ["BE", "BE", "GR", "GH"],
    "jahr": [2024, 2025, 2024, 2025],
    "menge": [120, 131, 95, 92]})
print(gemischt.to_string(index=False))
standort kanton  jahr  menge
    Bern     BE  2024    120
    Bern     BE  2025    131
    Chur     GR  2024     95
    Chur     GH  2025     92
print(gemischt.groupby("standort")["kanton"].nunique().to_dict())
{'Bern': 1, 'Chur': 2}
orte = gemischt[["standort", "kanton"]].drop_duplicates()
print(orte.to_string(index=False))
standort kanton
    Bern     BE
    Chur     GR
    Chur     GH
print(len(orte))
3

Output Zeile für Zeile

Prüfung Ergebnis Erklärung
Kantone je Standort Bern 1, Chur 2 Ein Standort kann nur in einem Kanton liegen. Die 2 ist der Widerspruch.
Stammdaten herausgezogen 3 Zeilen statt 2 Hätte jeder Standort genau einen Kanton, stünden hier zwei Zeilen. Die dritte ist der Tippfehler GH.

Die Prüfung n_distinct je Gruppe beziehungsweise nunique ist der Standardgriff für diese Fehlerklasse: Für jedes Merkmal, das zu einer übergeordneten Einheit gehört, darf es je Einheit nur einen Wert geben.

Interpretation und Ergebnissatz

Der Tippfehler ist nur möglich, weil der Kanton in jeder Messzeile wiederholt wird. In zwei Tabellen, einer für Standorte und einer für Messungen, gäbe es den Kanton genau einmal, und die Frage “welcher gilt” stellte sich nicht. Das ist derselbe Gedanke wie die Normalisierung in der Datenmodellierung: Jede Tatsache gehört an genau eine Stelle.

Chur hat in der Tabelle zwei verschiedene Kantone, GR und GH, und die herausgezogenen Stammdaten haben drei statt zwei Zeilen. Der Widerspruch entsteht durch die Wiederholung und verschwindet, sobald Standorte und Messungen getrennte Tabellen sind.

Frage und Datenlage

Die lange Tabelle aus Beispiel 1 soll als Berichtstabelle mit einer Spalte je Jahr erscheinen. Und was passiert, wenn eine Zeile versehentlich doppelt vorkommt?

Rechnung

bericht <- pivot_wider(lang, names_from = jahr, values_from = menge)
bericht
# A tibble: 3 × 3
  standort `2024` `2025`
  <chr>     <dbl>  <dbl>
1 Bern        120    131
2 Chur         95     92
3 Sion        143    150
doppelt <- bind_rows(lang, lang[1, ])
nrow(doppelt)
[1] 7
# Ohne Angabe, wie zusammenzufassen ist, entsteht eine Listenspalte
pivot_wider(doppelt, names_from = jahr, values_from = menge)
# A tibble: 3 × 3
  standort `2024`    `2025`   
  <chr>    <list>    <list>   
1 Bern     <dbl [2]> <dbl [1]>
2 Chur     <dbl [1]> <dbl [1]>
3 Sion     <dbl [1]> <dbl [1]>
pivot_wider(doppelt, names_from = jahr, values_from = menge,
            values_fn = length)
# A tibble: 3 × 3
  standort `2024` `2025`
  <chr>     <int>  <int>
1 Bern          2      1
2 Chur          1      1
3 Sion          1      1
pivot_wider(doppelt, names_from = jahr, values_from = menge,
            values_fn = mean)
# A tibble: 3 × 3
  standort `2024` `2025`
  <chr>     <dbl>  <dbl>
1 Bern        120    131
2 Chur         95     92
3 Sion        143    150
bericht = lang.pivot(index="standort", columns="jahr", values="menge")
print(bericht.to_string())
jahr      2024  2025
standort            
Bern       120   131
Chur        95    92
Sion       143   150
doppelt = pd.concat([lang, lang.iloc[[0]]], ignore_index=True)
print(doppelt.shape)
(7, 3)
try:
    doppelt.pivot(index="standort", columns="jahr", values="menge")
except ValueError as fehler:
    print(type(fehler).__name__, str(fehler)[:55])
ValueError Index contains duplicate entries, cannot reshape
print(doppelt.pivot_table(index="standort", columns="jahr", values="menge",
                          aggfunc="size").to_string())
jahr      2024  2025
standort            
Bern         2     1
Chur         1     1
Sion         1     1
print(doppelt.pivot_table(index="standort", columns="jahr", values="menge",
                          aggfunc="mean").to_string())
jahr       2024   2025
standort              
Bern      120.0  131.0
Chur       95.0   92.0
Sion      143.0  150.0

Output Zeile für Zeile

Schritt Python R Erklärung
Berichtstabelle 3 Zeilen, Spalten 2024 und 2025 dieselbe Tabelle Genau die Ausgangstabelle aus Beispiel 1. Die Umformung ist umkehrbar.
mit Duplikat 7 Zeilen 7 Zeilen Eine Zeile (Bern 2024) steht zweimal.
pivot ValueError: Index contains duplicate entries Warnung und eine Listenspalte pandas bricht ab, tidyr liefert eine Zelle mit zwei Werten darin. Beide sagen damit dasselbe: Der Schlüssel ist nicht eindeutig.
mit aggfunc="size" bzw. values_fn = length Bern 2024 = 2, alle anderen 1 dieselbe Tabelle So findet man die betroffene Zelle.
mit Mittelwert Bern 2024 = 120.0 120 Hier harmlos, weil beide Werte gleich sind. Bei verschiedenen Werten wäre der Mittelwert eine stille Entscheidung.

Interpretation und Ergebnissatz

Das Umformen ins Breite ist der Moment, in dem doppelte Schlüssel auffallen, und das ist sein zweiter Nutzen. Wer die Meldung mit einer Aggregationsfunktion wegdrückt, ohne nachzusehen, hat die Warnung in eine stille Annahme verwandelt. Erst zählen, dann entscheiden, ob Mittelwert, Summe oder das Beseitigen des Duplikats richtig ist.

Die lange Tabelle lässt sich verlustfrei ins breite Format zurückformen. Mit einer doppelten Zeile bricht pivot in pandas mit einem ValueError ab, während tidyr eine Listenspalte erzeugt; aggfunc="size" zeigt, dass die Zelle Bern 2024 zwei Werte enthält.

Verständnisfragen

Eine Tabelle hat die Spalten patient, blutdruck_vorher, blutdruck_nachher. Warum ist sie nicht aufgeräumt?

Der Messzeitpunkt steckt im Spaltennamen, ist aber ein Merkmal
Richtig. Aufgeräumt hiessen die Spalten patient, zeitpunkt und blutdruck. Jede Zeile wäre dann eine Messung statt eines Patienten.
Sie enthält zu wenige Spalten
Die Anzahl ist nicht das Kriterium, sondern was in den Spaltennamen steht.
Blutdruck gehört in zwei getrennte Tabellen
Eine Tabelle genügt, sie muss nur lang statt breit vorliegen.

Wann ist die breite Form trotzdem die richtige?

Immer, sie ist kompakter
Kompakter ja, aber für Filtern, Gruppieren und Zeichnen unbrauchbar.
Für eine Berichtstabelle, die ein Mensch liest, und für zeilenweise Vergleiche
Richtig. Die Veränderung zwischen zwei Jahren ist im breiten Format eine Subtraktion in einer Zeile, wie in Beispiel 1.
Wenn viele fehlende Werte vorliegen
Damit hat die Form nichts zu tun.

pivot beziehungsweise pivot_wider meldet doppelte Schlüssel. Was ist der erste Schritt?

Eine Aggregationsfunktion angeben, damit es durchläuft
Das beseitigt die Meldung, nicht die Ursache, und trifft eine stille Entscheidung.
Zählen, welche Zelle mehrfach belegt ist
Richtig, mit aggfunc="size" beziehungsweise values_fn = length. Erst wenn klar ist, warum es zwei Werte gibt, lässt sich entscheiden.
Die Tabelle vorher sortieren
Die Reihenfolge spielt keine Rolle.

In einer Messtabelle steht in jeder Zeile auch der Kanton des Standorts. Was ist das Risiko?

Die wiederholte Angabe kann sich widersprechen
Richtig, wie das GH in Beispiel 3. Eine Tatsache, die an vielen Stellen steht, wird irgendwann an einer Stelle falsch. Prüfen mit nunique je Standort.
Die Tabelle wird zu gross
Speicher ist hier nicht das Problem.
Kantone gehören nie in eine Auswertung
Sie gehören dazu, nur in eine eigene Tabelle, die man bei Bedarf verknüpft.

In einer Zelle stehen mehrere Messwerte, durch Schrägstrich getrennt. Warum ist das ein Problem, obwohl alle Werte vorhanden sind?

Solange sie in einer Zelle stehen, sind sie Text und keine Zahlen
Richtig. Weder Summe noch Mittelwert noch eine Grafik sind möglich; erst das Aufteilen in eine Zeile je Wert macht sie zugänglich.
Die Werte könnten in falscher Reihenfolge stehen
Die Reihenfolge ist nicht das Hindernis.
Es fehlt eine Kennung
Die Kennung steht in der Zeile und wird beim Aufteilen wiederholt.

Verlinkte Ressourcen