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.
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!
Ich selber tue das auch nicht.
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.
Indexe können Duplikate enthalten, dies wird jedoch bei manchen Operationen zu Fehlern führen. Generell möchte man doppelte Indizes vermeiden.
Wenn nur diese Funktionalität benötigt wird, gibt es noch at
und iat
, die keine
komplexere Indizierung unterstützen.