Data Wrangling

Data Wrangling
pandas
tidyverse
R
Python
Filtern, sortieren, gruppieren, umformen und verbinden in beiden Sprachen.

Kernideen

  • Fünf Verben decken den grössten Teil der Aufbereitung ab: filtern, auswählen, rechnen, sortieren, zusammenfassen.
  • Die Pipe beziehungsweise die Methodenkette bringt die Reihenfolge im Code in die Reihenfolge im Kopf.
  • Gruppieren heisst aufteilen, rechnen, zusammensetzen. In R bleibt die Gruppierung nach summarise bestehen, und das ist die häufigste Falle.
  • mutate nach group_by beziehungsweise transform behält alle Zeilen, summarise dampft sie ein.
  • Beim Klassieren entscheidet die Wahl des Werkzeugs über die Grenze: case_when mit < und cut teilen den Randwert verschieden zu.

Erklärung

Vorwissen: Tidy Data für die Zielform und R-Grundlagen beziehungsweise pandas für die Datenstrukturen. Woher die Daten kommen, steht unter Datenimport, was vorher zu prüfen ist unter Datenqualität.

Die fünf Verben

Jedes tut genau eine Sache und gibt einen Data Frame zurück. Genau deshalb lassen sie sich beliebig aneinanderreihen.

Verb in dplyr Frage pandas
filter() Welche Zeilen interessieren mich? .query(), .loc[maske]
select() Welche Spalten brauche ich? df[["a", "b"]]
mutate() Welche Spalte fehlt noch? .assign()
arrange() In welcher Reihenfolge? .sort_values()
summarise() Wie fasse ich zusammen? .agg()

Die Daten dieser Seite

24 Messungen, zwölf Wochen ab dem 5. Januar 2026, je eine für Bern und Chur, mit Verbrauch und Laufzeit. Die Werte sind fest eingetragen, damit R und Python dieselben Zahlen zeigen.

Gruppieren, und was danach noch gruppiert ist

group_by() verändert die Daten nicht, es markiert sie. Erst die folgende Operation wertet je Gruppe aus, und davon gibt es zwei Sorten:

nach group_by Ergebnis Zeilen pandas
summarise() eine Zeile je Gruppe weniger .groupby().agg()
mutate() Gruppenwert in jeder Zeile gleich viele .groupby().transform()

In R bleibt nach summarise() die äusserste Gruppierung bestehen. Wer danach weiterrechnet, rechnet unbemerkt je Gruppe statt über alles; .groups = "drop" oder ungroup() beendet das. pandas kennt dieses Verhalten nicht, legt die Gruppen dafür in den Index, was beim Weiterverarbeiten überrascht; as_index=False oder reset_index() hilft.

Klassieren

Aus einer Zahl eine Kategorie zu machen, sieht in beiden Sprachen einfach aus, und die Randwerte entscheiden:

Werkzeug Intervall der Wert 10 bei Grenze 10
case_when(wert < 10 ~ ...) oben offen fällt in die obere Klasse
cut(..., right = TRUE) (R-Vorgabe) (a, b] fällt in die untere Klasse
pd.cut(..., right=True) (Vorgabe) (a, b] fällt in die untere Klasse
pd.cut(..., right=False) [a, b) fällt in die obere Klasse

Die Grenze gehört deshalb in den Text der Auswertung, nicht nur in den Code.

Kurz nachgeschlagen

Aufgabe R Python
Zeilen filtern filter(df, x > 5, g == "A") df.query("x > 5 and g == 'A'")
Spalte berechnen mutate(df, y = a / b) df.assign(y=lambda d: d["a"] / d["b"])
sortieren arrange(df, desc(x)) df.sort_values("x", ascending=False)
zusammenfassen je Gruppe summarise(.by = g, m = mean(x)) df.groupby("g").agg(m=("x", "mean"))
Gruppenwert je Zeile mutate(.by = g, s = sum(x)) df.groupby("g")["x"].transform("sum")
Gruppierung lösen ungroup(), .groups = "drop" as_index=False, reset_index()
klassieren case_when(), cut() np.select(), pd.cut()
verbinden left_join(a, b, by = "id") a.merge(b, on="id", how="left")
Zeilen zählen je Gruppe count(df, g) df["g"].value_counts()

Beispiele

Frage und Datenlage

Aus den 24 Messungen: Wie viele Berner Werte liegen über 125? Wie sieht der Verbrauch je Laufzeitstunde aus, welches sind die beiden höchsten Werte, und wie lautet der Gesamtmittelwert?

Rechnung

nrow(filter(messungen, standort == "Bern", verbrauch > 125))
[1] 4
head(select(messungen, datum, verbrauch), 3)
       datum verbrauch
1 2026-01-05     118.2
2 2026-01-05      96.4
3 2026-01-12     125.4
mit_kennzahl <- mutate(messungen, pro_stunde = round(verbrauch / laufzeit, 2))
head(mit_kennzahl$pro_stunde, 4)
[1]  9.46 10.26  8.96 11.37
head(arrange(mit_kennzahl, desc(verbrauch))[, c("standort", "verbrauch")], 2)
  standort verbrauch
1     Bern     133.2
2     Bern     130.6
summarise(messungen, n = n(), mittel = round(mean(verbrauch), 2))
   n mittel
1 24 109.43
print(len(messungen.query("standort == 'Bern' and verbrauch > 125")))
4
print(messungen[["datum", "verbrauch"]].head(3).to_string(index=False))
     datum  verbrauch
2026-01-05      118.2
2026-01-05       96.4
2026-01-12      125.4
mit_kennzahl = messungen.assign(
    pro_stunde=lambda d: (d["verbrauch"] / d["laufzeit"]).round(2))
print(mit_kennzahl["pro_stunde"].head(4).tolist())
[9.46, 10.26, 8.96, 11.37]
print(mit_kennzahl.sort_values("verbrauch", ascending=False)
      .head(2)[["standort", "verbrauch"]].to_string(index=False))
standort  verbrauch
    Bern      133.2
    Bern      130.6
print(len(messungen), round(messungen["verbrauch"].mean(), 2))
24 109.43

Output Zeile für Zeile

Verb Ergebnis Erklärung
filter 4 Zeilen Bern über 125: die Werte 125.4, 130.6, 128.9 und 133.2.
select zwei Spalten, erste drei Zeilen Die ersten beiden Zeilen teilen sich das Datum 2026-01-05, je eine pro Standort.
mutate 9.46, 10.26, 8.96, 11.37 Verbrauch je Laufzeitstunde. Bern und Chur liegen hier nahe beieinander, obwohl der Verbrauch weit auseinanderliegt.
arrange 133.2 und 130.6, beide Bern die beiden höchsten Werte
summarise n = 24, Mittel 109.43 Über beide Standorte zusammen, also eine Zahl, die für keinen der beiden gilt.

Interpretation und Ergebnissatz

Der Gesamtmittelwert von 109.43 ist das Musterbeispiel einer Zahl, die rechnerisch stimmt und inhaltlich in die Irre führt: Sie liegt zwischen den Standorten und beschreibt keinen von beiden. Erst die Gruppierung in Beispiel 2 macht daraus eine brauchbare Aussage. Die abgeleitete Kennzahl pro_stunde zeigt zugleich, wofür mutate da ist: Sie ist die Grösse, die man eigentlich vergleichen will.

Vier Berner Messungen liegen über 125, die höchsten sind 133.2 und 130.6. Der Mittelwert über alle 24 Messungen beträgt 109.43, eine Zahl zwischen den beiden Standorten.

Frage und Datenlage

Der mittlere Verbrauch je Standort, absteigend sortiert. Einmal verschachtelt, einmal als Kette.

Rechnung

# von innen nach aussen zu lesen
arrange(summarise(group_by(messungen, standort),
                  mittel = round(mean(verbrauch), 2)),
        desc(mittel))
# A tibble: 2 × 2
  standort mittel
  <chr>     <dbl>
1 Bern      123. 
2 Chur       95.8
# dieselbe Rechnung als Kette
messungen |>
  group_by(standort) |>
  summarise(mittel = round(mean(verbrauch), 2)) |>
  arrange(desc(mittel))
# A tibble: 2 × 2
  standort mittel
  <chr>     <dbl>
1 Bern      123. 
2 Chur       95.8
print(messungen
      .groupby("standort", as_index=False)
      .agg(mittel=("verbrauch", "mean"))
      .round(2)
      .sort_values("mittel", ascending=False)
      .to_string(index=False))
standort  mittel
    Bern  123.12
    Chur   95.75

Output Zeile für Zeile

Standort Mittel Erklärung
Bern 123.12 1477.4 / 12
Chur 95.75 1149.0 / 12

Beide R-Fassungen liefern dasselbe. Die verschachtelte liest sich von innen nach aussen, also in umgekehrter Reihenfolge zum Ablauf; die Kette steht in der Reihenfolge, in der man sie denkt. |> reicht das Ergebnis links als erstes Argument nach rechts weiter, in pandas übernimmt die Methodenkette dieselbe Rolle.

Interpretation und Ergebnissatz

Der Abstand zwischen den Standorten beträgt 27.37, der Gesamtmittelwert aus Beispiel 1 liegt dazwischen und beschreibt keinen der beiden. Genau deshalb ist die Gruppierung fast immer der erste Schritt nach dem Filtern.

Bern verbraucht im Mittel 123.12, Chur 95.75. Verschachtelte Aufrufe und Kette liefern dasselbe Ergebnis, die Kette ist in der Denkreihenfolge geschrieben.

Frage und Datenlage

Der Monatsmittelwert je Standort, danach der Mittelwert dieser Monatswerte. In R einmal mit und einmal ohne .groups = "drop".

Rechnung

je_monat <- messungen |>
  mutate(monat = month(datum)) |>
  group_by(standort, monat) |>
  summarise(n = n(), mittel = round(mean(verbrauch), 2), .groups = "drop")
je_monat
# A tibble: 6 × 4
  standort monat     n mittel
  <chr>    <dbl> <int>  <dbl>
1 Bern         1     4  124. 
2 Bern         2     4  122. 
3 Bern         3     4  124. 
4 Chur         1     4   94.6
5 Chur         2     4   95.4
6 Chur         3     4   97.2
# Ohne .groups bleibt nach standort gruppiert
noch_gruppiert <- messungen |>
  mutate(monat = month(datum)) |>
  group_by(standort, monat) |>
  summarise(n = n(), mittel = round(mean(verbrauch), 2))

group_vars(noch_gruppiert)
[1] "standort"
summarise(noch_gruppiert, gesamt = round(mean(mittel), 2))   # je Standort!
# A tibble: 2 × 2
  standort gesamt
  <chr>     <dbl>
1 Bern      123. 
2 Chur       95.8
summarise(ungroup(noch_gruppiert), gesamt = round(mean(mittel), 2))
# A tibble: 1 × 1
  gesamt
   <dbl>
1   109.
je_monat = (messungen
    .assign(monat=messungen["datum"].dt.month)
    .groupby(["standort", "monat"], as_index=False)
    .agg(n=("verbrauch", "size"), mittel=("verbrauch", "mean")))
je_monat["mittel"] = je_monat["mittel"].round(2)
print(je_monat.to_string(index=False))
standort  monat  n  mittel
    Bern      1  4  123.80
    Bern      2  4  121.62
    Bern      3  4  123.92
    Chur      1  4   94.62
    Chur      2  4   95.38
    Chur      3  4   97.25
print(round(je_monat["mittel"].mean(), 2))
109.43
# Ohne as_index=False landen die Gruppen im Index
mit_index = messungen.groupby("standort")["verbrauch"].mean()
print(type(mit_index).__name__, mit_index.index.name, list(mit_index.index))
Series standort ['Bern', 'Chur']

Output Zeile für Zeile

Standort Januar Februar März Erklärung
Bern 123.80 121.62 123.92 je vier Messungen
Chur 94.62 95.38 97.25 Chur steigt über die drei Monate
Rechnung Ergebnis Erklärung
Mittel der sechs Monatswerte 109.43 Weil jede Gruppe gleich viele Messungen hat, stimmt es hier mit dem Gesamtmittel überein.
R ohne .groups group_vars meldet standort Die Tabelle ist noch nach Standort gruppiert, ohne dass man es ihr ansieht.
zweites summarise darauf zwei Zeilen: Bern 123.11, Chur 95.75 Gefragt war eine Zahl, geliefert werden zwei. Das Ergebnis sieht plausibel aus und beantwortet eine andere Frage.
nach ungroup() eine Zeile: 109.43 So war es gemeint.
pandas ohne as_index=False eine Series mit Index standort Kein Fehler, aber die Gruppen sind keine Spalte mehr. Beim nächsten merge fehlt die Spalte, auf die verknüpft werden soll.

Interpretation und Ergebnissatz

Beide Sprachen hinterlassen nach dem Gruppieren einen Zustand, den man mitdenken muss: R eine bestehende Gruppierung, pandas einen Index. Beide führen zu Ergebnissen, die nicht falsch aussehen. Die Gewohnheit, jede Zusammenfassung mit .groups = "drop" beziehungsweise as_index=False zu schreiben, kostet nichts und erspart die Fehlersuche.

Die Monatsmittel reichen von 94.62 bis 123.92, ihr Mittel beträgt 109.43. Ohne .groups = "drop" liefert die zweite Zusammenfassung statt einer Zahl zwei, nämlich 123.11 für Bern und 95.75 für Chur.

Frage und Datenlage

Die Werte 5, 10, 50, 100 und 250 sollen in klein (unter 10), mittel (bis 100) und gross eingeteilt werden. Was passiert mit den Werten auf der Grenze?

Rechnung

werte <- c(5, 10, 50, 100, 250)

case_when(werte < 10  ~ "klein",
          werte < 100 ~ "mittel",
          TRUE        ~ "gross")
[1] "klein"  "mittel" "mittel" "gross"  "gross" 
cut(werte, breaks = c(-Inf, 10, 100, Inf),
    labels = c("klein", "mittel", "gross"))
[1] klein  klein  mittel mittel gross 
Levels: klein mittel gross
cut(werte, breaks = c(-Inf, 10, 100, Inf),
    labels = c("klein", "mittel", "gross"), right = FALSE)
[1] klein  mittel mittel gross  gross 
Levels: klein mittel gross
werte = pd.Series([5, 10, 50, 100, 250])

rechts = pd.cut(werte, bins=[-np.inf, 10, 100, np.inf],
                labels=["klein", "mittel", "gross"])
print(list(zip(werte.tolist(), rechts.astype(str).tolist())))
[(5, 'klein'), (10, 'klein'), (50, 'mittel'), (100, 'mittel'), (250, 'gross')]
links = pd.cut(werte, bins=[-np.inf, 10, 100, np.inf],
               labels=["klein", "mittel", "gross"], right=False)
print(list(zip(werte.tolist(), links.astype(str).tolist())))
[(5, 'klein'), (10, 'mittel'), (50, 'mittel'), (100, 'gross'), (250, 'gross')]

Output Zeile für Zeile

Wert case_when mit < cut (Vorgabe, right = TRUE) cut mit right = FALSE
5 klein klein klein
10 mittel klein mittel
50 mittel mittel mittel
100 gross mittel gross
250 gross gross gross

case_when mit < prüft “kleiner als” und schiebt den Randwert nach oben. cut schneidet in der Voreinstellung Intervalle der Form (a, b], also oben geschlossen, und behält den Randwert unten. pandas verhält sich genau wie cut in R, samt dem Schalter right.

Interpretation und Ergebnissatz

Zwei Werkzeuge derselben Sprache teilen denselben Wert verschieden zu, und keines davon ist falsch. Bei Altersklassen, Preisstufen oder Toleranzgrenzen entscheidet genau das darüber, auf welcher Seite ein Grenzfall landet. Deshalb gehört die Grenze ausgeschrieben in den Bericht (“bis und mit 100” gegen “unter 100”), und bei einer Übernahme von Code aus der anderen Sprache ist sie der erste Punkt zum Nachrechnen.

Der Wert 10 fällt mit case_when in die Klasse mittel, mit cut in die Klasse klein; der Wert 100 einmal in gross und einmal in mittel. Mit right = FALSE verhält sich cut wie case_when.

Frage und Datenlage

Jede Messung soll ihren Anteil am Verbrauch ihres Standorts bekommen. Die Tabelle soll dabei alle 24 Zeilen behalten.

Rechnung

anteil <- messungen |>
  mutate(.by = standort,
         gruppensumme = sum(verbrauch)) |>
  mutate(anteil = round(verbrauch / gruppensumme * 100, 2))

head(anteil[, c("standort", "verbrauch", "gruppensumme", "anteil")], 4)
  standort verbrauch gruppensumme anteil
1     Bern     118.2       1477.4   8.00
2     Chur      96.4       1149.0   8.39
3     Bern     125.4       1477.4   8.49
4     Chur      92.1       1149.0   8.02
nrow(anteil)
[1] 24
anteil |>
  summarise(.by = standort,
            summe = sum(verbrauch), anteile = sum(anteil))
  standort  summe anteile
1     Bern 1477.4  100.01
2     Chur 1149.0  100.01
anteil = messungen.assign(
    gruppensumme=messungen.groupby("standort")["verbrauch"].transform("sum"))
anteil["anteil"] = (anteil["verbrauch"] / anteil["gruppensumme"] * 100).round(2)

print(anteil.head(4)[["standort", "verbrauch", "gruppensumme", "anteil"]]
      .to_string(index=False))
standort  verbrauch  gruppensumme  anteil
    Bern      118.2        1477.4    8.00
    Chur       96.4        1149.0    8.39
    Bern      125.4        1477.4    8.49
    Chur       92.1        1149.0    8.02
print(len(anteil))
24
print(anteil.groupby("standort")["verbrauch"].sum().round(1).to_dict())
{'Bern': 1477.4, 'Chur': 1149.0}
print(anteil.groupby("standort")["anteil"].sum().round(2).to_dict())
{'Bern': 100.01, 'Chur': 100.01}

Output Zeile für Zeile

Grösse Wert Erklärung
Zeilen 24, unverändert mutate(.by = ...) beziehungsweise transform fasst nicht zusammen, es verteilt den Gruppenwert auf jede Zeile.
Gruppensumme Bern 1477.4 steht in jeder der zwölf Berner Zeilen
Gruppensumme Chur 1149.0
erste Berner Messung 118.2, Anteil 8.00 Prozent 118.2 / 1477.4
erste Churer Messung 96.4, Anteil 8.39 Prozent 96.4 / 1149.0. Der kleinere Verbrauch ist der grössere Anteil, weil die Bezugsgrösse kleiner ist.
Summe der Anteile je 100.01 Nicht exakt 100, weil jeder Anteil einzeln auf zwei Stellen gerundet wurde.

Interpretation und Ergebnissatz

Das ist der Unterschied zwischen den beiden Gruppenoperationen: summarise beantwortet “wie ist die Gruppe insgesamt”, mutate mit .by beziehungsweise transform beantwortet “wie steht diese Zeile zu ihrer Gruppe”. Der Anteil zeigt auch, warum ein Vergleich absoluter Werte zwischen verschieden grossen Gruppen selten trägt. Und die Summe 100.01 ist die übliche Erinnerung: Gerundete Anteile addieren sich nicht zwangsläufig zu 100, was in einer Berichtstabelle vorab zu klären ist.

Alle 24 Zeilen bleiben erhalten, jede trägt die Summe ihres Standorts (Bern 1477.4, Chur 1149.0) und ihren Anteil daran. Die erste Churer Messung hat mit 8.39 Prozent den grösseren Anteil als die erste Berner mit 8.00 Prozent, obwohl sie absolut kleiner ist.

Verständnisfragen

Warum liefert summarise ohne vorheriges group_by genau eine Zeile?

Weil ohne Gruppierung die ganze Tabelle eine einzige Gruppe ist
Richtig. Die Zusammenfassung dampft jede Gruppe auf eine Zeile ein, hier also alles auf eine, wie die 109.43 in Beispiel 1.
Weil summarise immer nur eine Zeile zurückgeben kann
Mit Gruppierung gibt es eine Zeile je Gruppe.
Weil die übrigen Zeilen verworfen werden
Verworfen wird nichts, sie gehen in die Berechnung ein.

Nach group_by(standort, monat) und summarise wird ohne .groups = "drop" ein zweites Mal zusammengefasst. Was passiert?

Ein Fehler, weil die Tabelle schon zusammengefasst ist
Es gibt keinen Fehler, und das ist gerade das Problem.
Die zweite Zusammenfassung rechnet je Standort statt über alle
Richtig. In Beispiel 3 kommen zwei Zeilen (123.11 und 95.75) statt der erwarteten einen. Abhilfe: .groups = "drop" oder ungroup().
Das Ergebnis ist identisch
Es unterscheidet sich, sobald es mehr als einen Standort gibt.

Eine Auswertung soll für jede Zeile den Anteil am Gruppentotal ausweisen. Welche Operation ist richtig?

summarise je Gruppe
Das ergäbe eine Zeile je Gruppe, die einzelnen Messungen wären weg.
mutate mit .by beziehungsweise groupby().transform()
Richtig. Der Gruppenwert wird auf jede Zeile verteilt, die Zeilenzahl bleibt gleich.
Ein left_join der Gruppensummen
Funktioniert auch, ist aber der Umweg für dasselbe Ergebnis.

Eine Klassierung mit case_when(wert < 10 ~ "klein", ...) wird nach pandas als pd.cut(..., bins=[-inf, 10, ...]) übertragen. Worauf ist zu achten?

Der Wert genau auf der Grenze wechselt die Klasse
Richtig. cut schneidet (a, b] und ordnet die 10 der unteren Klasse zu, case_when mit < der oberen. Mit right=False stimmen beide überein.
pd.cut kann keine unendlichen Grenzen
-np.inf und np.inf sind zulässig.
Die Reihenfolge der Klassen dreht sich um
Die Reihenfolge folgt den Grenzen.

In pandas liefert df.groupby("g")["x"].mean() eine Series, deren Gruppen im Index stehen. Warum ist das beim Weiterarbeiten unpraktisch?

Die Gruppenspalte ist keine Spalte mehr, ein folgendes merge findet sie nicht
Richtig. as_index=False oder reset_index() macht sie wieder zur Spalte.
Der Index geht beim Speichern verloren
Er wird mitgeschrieben, das ist nicht das Problem.
Series können keine Gruppen enthalten
Können sie, eben über den Index.

Verlinkte Ressourcen