Pandas ist eine mächtige Python-Bibliothek, um mit Daten zu arbeiten. Ihre Beliebtheit erklärt sich neben einer guten Performance1 auch durch eine hohe Ergonomie. Viele Aktionen, die man mit Pandas-Daten ausführen kann, lassen sich mit Standard-Python-Operatoren ausdrücken. Leider führt dies auch dazu, dass sich Pandas fast wie eine Mini-Sprache, welche in Python eingebettet ist, anfühlt. Das systematische Erlernen dieser Sprache ist schwierig, zumal die Dokumentation von Pandas zwar umfangreich, aber meiner Meinung nach eher als Referenz als als Lernmittel gestaltet ist.

Man muss aber nicht alle Funktionalität von Pandas verstehen, um es gewinnbringend einzusetzen2. Deswegen möchte ich in diesem Artikel die schwieriger verständlichen Grundkonzepte von Pandas darlegen, die eine Hürde für Einsteiger darstellen können. Ich werde die "Standardbibliothek" von Pandas nicht systematisch erläutern, sondern mich auf Broadcasting, Indizierung, Gruppierung und Anwendung benutzerdefinierter Funktionen konzentrieren. "Standardmethoden" wie sort_values, head, tail und mean sind im Vergleich zu den hier behandelten Konzepten leichter verständlich, und werden deswegen nicht angeschnitten. Diese Webseite ist die statische Repräsentation einer Org-Mode-Datei, und kann hier herunter geladen werden, um selber mit den Beispielen zu spielen (pandas und Python sollten natürlich installiert sein).

Zuerst werden wir uns anschauen, was Pandas überhaupt so besonders macht. "Dataframes" und Serien sind hie Hauptobjekte, die in Pandas verwendet werden, und sind eine Abstraktion, die mittlerweile auch in anderen Bibliotheken reimplementiert werden.

Dataframes und Series

Der "Dataframe" ist das Hauptobjekt, mit dem wir in Pandas in 80% der Fälle arbeiten werden. Salopp gesagt ist ein Dataframe eine Excel-Tabelle, auf der wir mit Python arbeiten können3. So sieht ein Dataframe aus:

  import pandas as pd
  import numpy as np
  df = pd.DataFrame({"eins": [1, 2, 3], "zwei": [3, 4, 5]})
  df
   eins  zwei
0     1     3
1     2     4
2     3     5

Wie wir sehen, habe ich aus einem Dictionary von Listen einen Dataframe erstellt. Pandas kann Dataframes aus vielen anderen Daten einlesen, so z.B. aus CSV-, JSON- und Parquet-Dateien oder sogar externen Diensten wie Datenbanken. Dataframes können auch einfach in diese Formate exportiert werden, um in anderen Systemen mit den Daten weiter zu arbeiten.

In unserem Fall ist der Spaltenname der Dictionary-Schlüssel, die Werte der Spalte stehen in der Liste. Soweit, so unüberraschend. Eine Spalte ist allerdings hinzu gekommen: Der Index. Der Index identifiziert Reihen in einem Dataframe, meistens eindeutig4. In diesem Fall wurde der Index von Pandas automatisch erzeugt, wir können aber auch einen Index selber angeben.

  df = pd.DataFrame({"eins": [1, 2, 3], "zwei": [3, 4, 5]}, index=[5, 10, 20])
  df
    eins  zwei
5      1     3
10     2     4
20     3     5

Generell sollte der Index der Wert sein, der einen Datensatz eindeutig identifiziert. Oft reicht der von Pandas automatisch definierte Index aus, wenn man aber einen fachlichen Index kennt (z.B. Artikel-IDs oder eindeutige Zeitpunkte), kann man auch diese als Index verwenden. Dies kann einige Abfragen auf dem Index unter Umständen beschleunigen.

Was kann ich mit einem Dataframe anfangen? Ich kann auf Daten zugreifen, doch das werden wir im nächsten Abschnitt besprechen. Zuerst werden wir uns noch den Bestandteilen des Dataframe widmen: Den Serien.

Ich kann auf die Spalten eines Dataframe zugreifen, als ob es Objekt-Properties wären.

  df.eins
5     1
10    2
20    3
Name: eins, dtype: int64

Wie wir sehen, haben wir die Werte der Spalte "eins" aus dem Dataframe extrahiert. Wir haben jetzt eine "Series" oder Serie in der Hand. Serien sind Folgen von Werten eines Datentyps (im Beispiel int64), und enthalten immer noch den Index. Serien verfügen über andere Methoden und werden anders behandelt als Dataframes — wenn also der Code nicht funktioniert, wie er soll, kann das durchaus daran liegen, dass mit einer Serie statt mit einem Dataframe oder umgekehrt gearbeitet wird. Series speichern ihre Daten in einem "Array", welches von der Bibliothek numpy bereitgestellt wird.

df.eins.array
<NumpyExtensionArray>
[1, 2, 3]
Length: 3, dtype: int64

Wie man sieht, enthält das Array keinen Index mehr. Die explizite array-Methode benötigt man selten, es ist jedoch gut zu wissen, dass die meisten Numpy-Methoden mit Series umgehen können, und dabei sehr effizient sind (dazu später mehr).

Wofür benötige ich aber diese Datenstrukturen? Bisher konnte ich alle Funktionalität mit in Python eingebauten Datentypen erschlagen. Die Pandas- und Numpy-Datentypen haben jedoch neben dem Indizieren (welche wir im übernächsten Kapitel besprechen) noch eine andere Funktionalität, das "Broadcasting".

Daten modifizieren

Durch Broadcasting können wir Operationen auf mehreren Elementen des Dataframe gleichzeitig ausführen.

  df + 1
    eins  zwei
5      2     4
10     3     5
20     4     6

Die Addition wurde hier auf allen Elementen des Dataframes ausgeführt. Operationen mit Skalaren sind im Gegensatz zu den fortgeschrittenen Broadcasting-Regeln leicht verständlich und auch am nützlichsten. Der Rückgabewert ist ein neuer Dataframe, den ich wie gehabt verwenden kann.

Broadcasting funktioniert auch auf Serien:

  added = df.eins + 1
  added
5     2
10    3
20    4
Name: eins, dtype: int64

Auch numpy-Funktionen werden automatisch gebroadcastet (und auch effizient ausgeführt). So kann ich z.B. Durchschnitte via np.mean berechnen.

  (np.mean(df.eins), np.mean(df))
2.0 3.0

Der erste Aufruf berechnet den Durchschnitt der Spalte "eins", der zweite den des gesamten Dataframes. Auch Vergleiche werden gebroadcastet:

  df == 1
     eins   zwei
5    True  False
10  False  False
20  False  False

Dies scheint im ersten Moment nicht besonders sinnvoll. Wir werden jedoch gleich sehen, wie ein solcher boolescher Dataframe nützlich sein kann.

Natürlich kann man auch mit Python-Bordmitteln über einen Dataframe iterieren. Die Standard-Python-Iteration iteriert bei Serien über die Werte, bei Dataframes über die Spaltennamen. Nützlicher sind bei Dataframes meistens iterrows und itertuples, welche über die Reihen des Dataframe iterieren. Meistens ist eine vektorisierte Operation wie die oben verwendete Numpy-Funktion jedoch von der Performance überlegen, da sie parallelisiert werden kann.

Indizieren und Slicen

Broadcasting ist zwar eine nützliche Eigenschaft, ohne einfachen Zugriff auf die Daten zu haben, können wir aber die volle Macht von Pandas nicht ausnutzen. Die vielfältigen und effizienten Indizierungs- und Slicing-Möglichkeiten sind das Hauptfeature von Pandas, leider aber auch durchaus komplex. Ich werde versuchen, das nützlichste Handwerkszeug zusammenzufassen. Es gibt aber viele weitere Indizierungsfunktionalitäten in Pandas.

Einfaches Indizieren

Die beiden einfachsten Varianten sind der offset-basierte Zugriff über iloc, sowie der label-basierte Zugriff über loc.

    print(df.iloc[0])
    print(df.loc[5])
eins    1
zwei    3
Name: 5, dtype: int64
eins    1
zwei    3
Name: 5, dtype: int64

In beiden Fällen erhalten wir die Werte der ersten Reihe als Serie. Für loc musste ich jedoch den Index (oder das "Label") der ersten Zeile angeben, nicht den Offset innerhalb des Dataframe. Ein Pandas-Dataframe unterstützt auch die normale Python-Indizierungs-Syntax. Diese funktioniert wie eine Mischung aus loc und iloc. Aus Gründen der Lesbarkeit ist es meist einfacher, explizit label- oder offset-basierte Indizierung zu verwenden.

Wie wir sehen, haben wir als Rückgabewert der Indizierungs-Aktionen eine Serie mit Werten erhalten, die als Index den Spaltennamen verwenden.

loc und iloc lassen sich allerdings nicht nur mit Namen und einzelenen Indizes verwenden5, sondern auch, ähnlich wie normale Python-Indizierung, mit Slices.

  print(df.loc[5:20])
  print(df.iloc[0:3])
    eins  zwei
5      1     3
10     2     4
20     3     5
    eins  zwei
5      1     3
10     2     4
20     3     5

In diesem Falle haben wir uns alle Reihen zwischen den Labels 5 und 20 (inklusive), sowie alle Reihen zwischen Offset 0 und 3 (inklusive) ausgeben lassen, was dem gesamten Dataframe entspricht. Möchte ich alle Reihen auswählen, kann ich : als Slice verwenden, dies entspricht einer Slice vom Anfang bis zum Ende des Dataframe. Der Rückgabewert bei der Indizierung mit Slices ist allerdings ein Dataframe, keine Serie mehr, also Obacht!

Ich kann auch direkt eine Liste von Indizes oder Namen übergeben, um nur diese Indizes aus dem Dataframe herauszupicken.

  df.loc[[5, 20, 10]]
    eins  zwei
5      1     3
20     3     5
10     2     4

Wie man sieht, kann ich auch die Reihenfolge der Reihen verändern.

Mehrdimensionales Indizieren

Bisher haben wir nur eindimensional indiziert, entweder per Label oder per Offset. Pandas unterstützt aber auch mehrdimensionale Indizierung, um gleichzeitig Reihen und Spalten auszuwählen.

  df.loc[5, "eins"]
1

In diesem Fall erhalten wir einen skalaren Wert zurück, und zwar den Wert der Spalte "eins" der Reihe mit dem Label "5". Auch die anderen Dimensionen des Index kann ich slicen, auch beide gleichzeitig, oder wie oben Listen von Indizes übergeben.

  print(df.loc[5:10, "eins":"zwei"])
  print(df.loc[[5, 20], ["eins", "zwei"]])
    eins  zwei
5      1     3
10     2     4
    eins  zwei
5      1     3
20     3     5

Und zu guter Letzt kann ich neue Spalten erzeugen, indem ich dem entsprechenden Slice eine neue Series zuweise. Dies funktioniert auch mit komplizierteren Slices, oder boole'schem Indizieren wie im nächsten Abschnitt beschrieben. Nicht verfügbare Werte werden automatisch mit einem NaN-Wert befüllt.

  new_column = df.copy()
  new_column.loc[5:10, "drei"] = [1, 2]
  new_column
    eins  zwei  drei
5      1     3   1.0
10     2     4   2.0
20     3     5   NaN

Auch hier können wie beim Indizieren nicht nur skalare Daten übergeben werden, sondern Werte in verschiedenen Formen, die Pandas automatisch versucht, in die korrekte Form zu überführen.

Boole'sches Indizieren

Neben den oben beschriebenen Indizierungsmöglichkeiten gibt es noch eine weitere wichtige Indizierung von Dataframes, die fast die nützlichste Indizierungsart ist: Die boolesche Indizierung. Sie erlaubt es mir, aus einem Dataframe Sub-Dataframes zu extrahieren. Die Extraktion wird durch eine boolesche Serie gesteuert: Steht in einer Reihe der Serie True, wird die Reihe im Dataframe beibehalten, wenn nicht, ist sie nicht im Ergebnis-Dataframe enthalten.

  print(df.eins == 1)
  print(df.loc[df.eins == 1])
5      True
10    False
20    False
Name: eins, dtype: bool
   eins  zwei
5     1     3

Diese Funktionalität erlaubt es, Abfragen auf Dataframe-Daten ähnlich wie ein SQL SELECT durchzuführen: Boolesche Serien können über die bitweisen Operatoren von Python kombiniert werden. Hierbei müssen allerdings Klammern gesetzt werden, da sonst die Operatorpräzedenz dazwischenfunkt. Serien selber verfügen über eine große Anzahl an Methoden, die ebenfalls boolesche Serien erzeugen, die dann zum Indizieren benutzt werden können. Als kleiner Geschmackshappen für eigene Versuche:

  df.loc[(df.eins == 1) & df.zwei.isin([3, 4, 5])]
   eins  zwei
5     1     3

Diese Abfrage gibt mir alle Reihen, in denen der Wert der Spalte "eins" 1 und der Wert der Spalte "zwei" entweder 3, 4 oder 5 ist.

Aber Achtung: Wer boole'sch indiziert, benötigt eine Serie. Beim Indizieren mit einem boole'schen Dataframe wird stattdessen maskiert. Das Ergebnis diese Operation ist ein Dataframe, bei dem an allen Stellen, an denen der boole'sche Dataframe einen False-Wert hat, ein "nicht verfügbar"-Wert eingefügt wird. Alle anderen Werte werden unverändert beibehalten.

Abkürzungen

Die "normale" Python-Indizierung wird von Dataframes auch unterstützt, jedoch ist das Verhalten oft subtil anders als bei iloc und loc. Generell versucht die Indizierung über eckige Klammern "intuitiv" zu sein, dies kann jedoch auch schief gehen. Einige Abkürzungen, die ich häufig nutze, sind das Extrahieren von Spalten mit df[["eins","zwei"]] und das boolesche Indizieren wie oben beschrieben. Bei der interaktiven Analyse ist es oft schneller, auf loc und Konsorten zu verzichten, beim Debuggen hilft es aber oft, dann doch explizit zu sein und loc zu verwenden.

Gruppierung und benutzerdefinierte Funktionen

Zwei weitere Funktionen sind für den täglichen Umgang mit Pandas nützlich: Gruppierungen und das Verändern von Dataframes mit benutzerdefinierten Funktionen.

Gruppieren

Das Gruppieren von Daten in Pandas ist eine wichtige Funktionalität, die jedoch leider nicht ganz einfach zu benutzen ist. Über Gruppierungen kann ein Dataframe aufgetrennt, die aufgetrennten Dataframes individuell bearbeitet und verändert, und anschließlich wieder zu einem Dataframe zusammengefügt werden. In gewisser Hinsicht entspricht dies einem "Map-Reduce"-Ansatz, der im Big-Data-Bereich weit verbreitet ist. Natürlich kann man diese Funktionalität auch via selbst geschriebenem Python-code realisieren, die Implementierung in Pandas wird einer eigenen Python-Implementierung jedoch immer überlegen sein.

Eine Gruppierung in Pandas kann über groupby erzeugt werden. Das Ergebnis dieser Gruppierung ist leider ein opakes Objekt, welches Python nicht sinnvoll ausgeben kann (im Gegensatz zu Dataframes). Im häufigsten Fall möchte ich einen Dataframe nach dem Inhalt einer Spalte gruppieren, und eine statistische Metrik erheben.

  df = pd.DataFrame({"type":
                     [1, 2, 1, 2],
                     "value":
                     [5, 8, 3, 2]})
  print(df)
  print(df.groupby("type").mean())
   type  value
0     1      5
1     2      8
2     1      3
3     2      2
      value
type       
1       4.0
2       5.0

Wir erstellen einen neuen Dataframe mit Daten, die sich interessanter gruppieren lassen, führen eine Gruppierung nach der Spalte "type" aus, und berechnen den Durchschnitt der Gruppen. Die Ergebnisse sind wie erwartet: Für "type=1" erhalten wir den Durchschnitt (5+3)/2 = 4, für "type=2" ist der Durchschnitt (8 + 2) / 2 = 5.

Der Aufruf von selbstdefinierten Funktionen auf Gruppen ist aus Performance-Gründen leider komplex, weswegen ich hier nur den die Grundfunktionalität zeige.

Transformation mit benutzerdefinierten Funktionen

Die letzte wichtige Grundfunktionalität ist die Anwendung selbstdefinierter Funktionen auf einen Dataframe (oder via Indizierung daraus extrahierter Teile). Dies kann man durch die Funktion apply bewerkstelligen. Man muss jedoch aufpassen, welchen Datentyp man gerade hat, da apply auf Serien und Dataframes leicht anders funktioniert. Am einfachsten ist es auch hier, erst einmal Numpy-Funktionen zu verwenden.

Das wichtigste Argument für apply ist neben der anzuwendenen Funktion das axis-Argument. Dies gibt an, ob die Funktion auf Spalten oder auf Zeilen angewendet werden soll. Da ich Spalten leicht als Serie extrahieren kann, verwende ich eher selten apply auf Spalten, sondern meistens auf Zeilen. So kann ich z.B. herausfinden, in welchen meiner Spalten Werte fehlen, indem ich erst np.isnan anwende, um fehlende Werte zu finden, und dann mit einer zeilenweisen Anwendung von np.any alle Reihen zu finden, in denen mindestens ein nicht verfügbarer Wert vorkommt. Mit axis=0 könnte ich dann Spalten finden, in denen Werte fehlen.

  print("Fehlende Werte") 
  print(np.isnan(df))
  print("\nZeilen mit fehlenden Werten")
  print(np.isnan(df).apply(np.any, axis=1))
Fehlende Werte
    type  value
0  False  False
1  False  False
2  False  False
3  False  False

Zeilen mit fehlenden Werten
0    False
1    False
2    False
3    False
dtype: bool

Statt numpy-Funktionen kann ich auch eigene Funktionen verwenden. Die Funktion erhält beide Male als Argument eine Serie, einmal spaltenweise, einmal zeilenweise. So kann ich z.B. die Werte in der Spalte "value" mit apply wie folgt um eins erhöhen:

df.apply(lambda x: x.value + 1, axis=1)
0    6
1    9
2    4
3    3
dtype: int64

Fazit

Ein derart kurzer Artikel kann unmöglich in alle Facetten einer so vielseitigen Bibliothek wie Pandas einführen. Ich habe versucht, hier auf die Stolpersteine einzugehen, die ich bei der Verwendung von Pandas gefunden habe. Mit einem Grundverständnis über die Organisation von Dataframes, dem Indizieren, Gruppieren und der Verwendung von apply sind schon viele interessante Datentransforamtionen möglich.


1

Hohe Performance ist in einer interpretierten Sprache wie Python nur mit ein paar Klimmzügen zu erreichen. Viele Grundoperationen von Pandas und den unterliegenden Bibliotheken sind in C oder C++ geschrieben. Hinzu kommt das globale Interpreter-Lock, welches parallele Ausführen von Code schwierig macht. Glücklicherweise nehmen uns Bibliotheken wie Pandas diese Arbeit ab!

2

Ich selber tue das auch nicht.

3

Mittlerweile kann man auch in Excel auf Excel-Tabellen mit Python arbeiten, aber als Entwickler gefällt mir die andere Richtung besser. Ich habe nichts gegen Excel, aber Python-Code fügt sich besser in die sonstige Entwicklungsinfrastruktur ein.

4

Indexe können Duplikate enthalten, dies wird jedoch bei manchen Operationen zu Fehlern führen. Generell möchte man doppelte Indizes vermeiden.

5

Wenn nur diese Funktionalität benötigt wird, gibt es noch at und iat, die keine komplexere Indizierung unterstützen.