10.1. Daten bereinigen mit Pandas#
Bisher haben wir uns nur der Beschaffung von Daten beschäftigt. Die extrahierten Daten haben wir meist als Pandas Dataframe dargestellt und gespeichert; oder wir haben extrahierte Texte direkt als Plaintextdateien gespeichert. In diesem Kapitel werden wir etwas tiefer in Pandas einsteigen und anhand eines Beispiels einige typische Datenbereinigungs- und -transformationsschritte kennenlernen.
10.1.1. Einstieg Pandas#
Lest euch zunächst die beiden Anleitungen “What kind of data does pandas handle?” und “How do I select a subset of a DataFrame?” auf der Seite Getting Started Tutorials durch.
Ruft anschließend dieses Pandas Cheatsheet auf und beantwortet mithilfe der Tutorial-Seiten und des Cheat Sheets die folgenden Fragen:
Wie hängen Pandas Dataframe- und Series-Objekte zusammen?
Was ist der Unterschied zwischen den Methoden
.loc()
,.iloc()
,.at()
und.iat()
?
10.1.2. Daten extrahieren und bereinigen#
In der Praxisaufgabe auf dem Übungsblatt 12 solltet ihr die Links zu Tierfotos von der Seite https://www.pinterest.com/ideas/animals/925056443165/ extrahieren. In diesem Beispiel scrapen wir nicht die Links zu den Fotos, sondern Kommentare zu den einzelnen Fotos zusammen mit den Usernamen von Kommentator:innen. Anschließend werden wir den Pandas Dataframe mit den extrahierten Daten bereinigen und bearbeiten, und zuletzt erstellen wir zwei beispielhafte Grafiken zur Visualisierung der Daten.
from selenium import webdriver
from selenium.webdriver.support.wait import WebDriverWait
from selenium.webdriver.common.by import By
from selenium.webdriver.support import expected_conditions as EC
import pandas as pd
Als Beispiel verwenden wir zwei Pins, für die zunächst die Kommentare sowie die Usernamen der Kommentator:innen extrahiert werden. Diese Daten speichern wir zusammen mit der URL zum Tierfotopin zunächst als Python Dictionary, das wir in einen Pandas Dataframe umwandeln:
comments_dict = {"link":[], "commentator":[], "comment":[]}
tierlinks = [
"https://www.pinterest.de/pin/206321226666310819/",
"https://www.pinterest.de/pin/1108730001991298438/",
"https://de.pinterest.com/pin/314759461475004846/"
]
driver = webdriver.Chrome()
for link in tierlinks:
driver.get(link)
wait = WebDriverWait(driver, 20)
comment_elems = wait.until(
EC.visibility_of_all_elements_located((By.XPATH, "//div[@data-test-id='author-and-comment-container']/span/div/span/span[3]/span"))
)
commentator_elems = wait.until(
EC.visibility_of_all_elements_located((By.XPATH, "//div[@data-test-id='author-and-comment-container']/span/div/span/span[2]/a"))
)
if len(comment_elems) > 0: # oder len(commentator_elems); das ist egal
comments = [comment.text for comment in comment_elems]
commentators = [commentator.get_attribute("href") for commentator in commentator_elems]
comments_dict["link"].extend([link]*len(comments)) # [link] erstellt eine Liste mit einem Element
comments_dict["comment"].extend(comments)
comments_dict["commentator"].extend(commentators)
driver.quit()
comments_df = pd.DataFrame.from_dict(comments_dict)
comments_df
Eine kurze Durchsicht der extrahierten Daten zeigt, dass nicht alle Kommentare extrahiert wurden, sondern nur die Kommentare, die beim Aufruf einer Tierfotoseite sichtbar sind. Für unsere Zwecke reichen uns aber diese Kommentare. Wir haben außerdem nur Kommentare von zwei Tierfotoseiten extrahiert, sodass der Dataframe überschaubar ist. Bei sehr großen Dataframes kann es sinnvoll sein, sich nicht den gesamten Dataframe ausgeben zu lassen, sondern nur eine bestimmte Anzahl von Zeilen. Dazu können die Pandas Dataframe-Methoden .tail()
und .head()
verwendet werden, oder eine Slicing-Operation:
comments_df.head(10) # erste 10 Zeilen
comments_df.tail(5) # erste 10 Zeilen
comments_df.iloc[4:12] # Zeilen 4-11
Zeilen können auch mithilfe einer logischen Abfrage ausgewählt werden, zum Beispiel:
# Die Spalte commentator benennen wir allerdings erst später in commentator_id um (s.u.)
comments_df.loc[comments_df["commentator_id"] == 5] # Zeilen mit commentator_id == 5
Es können auch einzelne Spalten oder nur bestimmte Spalten ausgewählt werden:
# Spalten commentator bis comment
comments_df.loc[:, "commentator":"comment"]
# Zugriff auf einzelne Spalte
comments_df["comment"]
comments_df.comment
comments_df.loc[:, "comment"]
Die Durchsicht des Dataframes zeigt, dass für einige Zellen in der Spalte comment leer zu sein scheinen. Die Kommentare sind immer dann leer, wenn ein:e User:in ein Bild als Kommentar geposted hat anstelle eines Textkommentars. Tatsächlich sind diese Zellen in unserem Dataframe aber nicht leer:
type(comments_df.at[10, "comment"])
Um das Fehlen der Werte in unserem Dataframe zu kennzeichnen, können wir den speziellen Wert NA
einsetzen:
# Leere Zeichenketten durch NA Werte ersetzen
df = comments_df.replace('', pd.NA, inplace=True)
Der Wert NA markiert das Fehlen von Werten. In Pandas können Zellen, die fehlende Werte enthalten, mithilfe spezieller Methoden abgefragt und bearbeitet werden, so zum Beispiel .isna()
oder .fillna()
. Hier könnt ihr nachlesen, wie fehlende Werte in Pandas-Datenobjekten allgemein behandelt werden.
Als nächstes überprüfen wir eine Zelle mit einem Kommentar und überprüfen, ob sich am Anfang oder Ende der Zeichenkette überflüssige Leerzeichen befinden. Das ist bei der Extraktion von Textinhalt häufig der Fall und diesem Problem sind wir im Laufe des Semesters schon einige Male begegnet (z.B. beim Scrapen der Tags auf der Quotes to Scrape-Seite).
comments_df.at[2, "comment"]
Tatsächlich befindet sich am Anfang des ausgewählten Kommentars überflüssige Leerzeichen. Leerzeichen am Anfang und Ende einer einzelnen Zeichenkette können mithilfe der Methode .strip()
entfernt werden; die Methode .str.strip()
entfernt Leerzeichen für jedes Element in einer Spalte eines Pandas-Dataframes (bzw. in einem Pandas Series-Objekt, denn das ist ja dasselbe):
# Leading und trailing Whitespace entfernen mit strip()
comments_df.at[2, "comment"].strip()
# Leading und trailing whitespace für eine gesamte Spalte entfernen mit .str.strip()
comments_df['comment'] = comments_df['comment'].str.strip()
comments_df.at[2, "comment"] # überprüfen: hat es geklappt?
Ein weiterer Verarbeitungsschritt ist die Anonymisierung der Kommentator:innen. Je nach Forschungsfrage interessiert nicht unbedingt, welche:r Nutzer:in welchen Kommentar verfasst hat, sondern beispielsweise nur, ob dieselben Kommentator:innen ähnliche Bilder kommentieren oder wie viele Kommentare jede:r Nutzer:in hinterlassen hat. Dazu müssen wir die konkreten Nutzernamen nicht kennen; es reicht aus, wenn wir jeder Kommentator:in eine einzigartige ID zuteilen und in unserer Analyse nur die IDs betrachten. Durch das Anonymisieren der Nutzernamen gehen wir außerdem sicher, dass unser Datensatz nicht die Auflagen zur Speicherung und Nutzung personenbezogener Daten laut DSGVO verletzt (siehe Abschnitt 6.1).
# Die factorize()-Methode ordnet jedem einzigartigen Wert eine eindeutige ID zu und gibt ein Tupel zurück, das aus einem Array von Labels und einem Index mit den einzigartigen Werten besteht.
labels, unique = pd.factorize(comments_df['commentator'])
labels
unique
comments_df['commentator'] = labels
comments_df # überprüfen
Nachdem wir die Nutzernamen durch IDs ersetzt haben, passt der Spaltenname commentator nicht mehr so ganz und wir werden die Spalte umbenennen. Das Argument inplace=True bewirkt dabei, dass die Änderung direkt im bestehenden Dataframe vorgenommen wird, ohne dass eine Kopie des Objekts erstellt wird.
# Spalte commentator in commentator_id umbenennen
comments_df.rename(columns={"commentator": "commentator_id"}, inplace=True)
comments_df
# Alternative
comments_df.columns = ["link", "commentator_id", "comment"]
10.1.3. Daten zwischenspeichern#
Häufig erfolgt die Analyse der extrahierten Daten nicht unmittelbar nach der Datenextraktion, sondern die Daten werden zwischengespeichert und später wieder eingelesen. Den bereinigten und anonymisierten Dataframe speichern wir deswegen im Folgenden auf dem Computer. Neben der bereits bekannten Pandas Dataframe-Methode .to_csv()
gibt es eine Vielzahl anderer Methoden zum Schreiben von Pandas-Objekten. Einen Überblick über alle Datenformate und Methoden zum Schreiben von Daten findet ihr unter https://pandas.pydata.org/docs/user_guide/io.html.
CSV-Dateien sind nicht immer die beste Wahl. Wenn Daten nur zwischengespeichert und später wieder eingelesen und weiterverarbeitet werden sollen, dann eignet sich zum Beispiel das Python-spezifische Datenformat pickle. Für größere Dataframes und wenn der Dataframe in einer anderen Programmiersprache wie R weiter bearbeitet werden soll, eignet sich dagegen das Datenformat feather. Um die Methode .to_feather()
verwenden zu können, muss jedoch zuvor das Modul pyarrow installiert werden. Es muss ausnahmsweise nicht importiert werden, weil die Methode unter der Motorhaube automatisch auf das Modul zurückgreift. Zum Speichern besonders großer Datenobjekte wird häufig das Datenformat HDF5 empfohlen (zum Beispiel in diesem Blogartikel.
# CSV
comments_df.to_csv("comments_df.csv", index=False, encoding="utf-8")
# Pickle
comments_df.to_pickle("comments_df.pkl")
# Feather
import sys
!conda install --yes --prefix {sys.prefix} pyarrow
comments_df.to_feather("comments_df.feather")
Bei der Arbeit mit sehr großen Datenmengen lohnt sich außerdem gegebenenfalls der Umstieg von Pandas auf Polars, eine neue und deutlich effizientere Bibliothek zur Arbeit mit Dataframes. Wer bereits Pandas und/oder R und insbesondere R dplyr kennt, sollte beim Umstieg jedoch keine Probleme haben, denn die Polars-Syntax ist sehr ähnlich. Einen Vergleich zwischen der Polars und Pandas Syntax findet ihr hier. Für einen Vergleich zwischen Polars und R empfehle ich diese Seite.
10.1.4. Daten visualisieren#
Wie genau die extrahierten Daten visualisiert und analysiert werden sollen, hängt natürlich vor allem von der Forschungsfrage und der Art der Daten ab. Im Folgenden betrachten wir deswegen nur ganz allgemein zwei Beispiele, wie unsere Kommentardaten visualisiert werden könnten, um einen ersten Überblick über die Zusammensetzung der Daten zu gewinnen. Hierfür verwenden wir beispielhaft die beiden Bibliotheken Matplotlib und Seaborn. Beide werden zunächst installiert:
import sys
!conda install --yes --prefix {sys.prefix} matplotlib
!conda install --yes --prefix {sys.prefix} seaborn
Zuerst visualisieren wir mit Matplotlib die Anzahl der Kommentare je Pin. Dazu muss zuerst eine neue Spalte erstellt werden, welche die Anzahl der Kommentare je Pin enthält.
# Anzahl Kommentare je Pin
comments_per_pin = comments_df.groupby(by="link")["comment"].nunique()
type(comments_per_pin)
comments_per_pin
Durch diese Operation hat sich aber die Struktur des Dataframes verändert. Bevor die Visualisierung erstellt werden kann, muss erst die Struktur des Dataframes angepasst werden:
pin_comments_df = comments_per_pin.to_frame(name="comments_no") # Spaltenname setzen
pin_comments_df
pin_comments_df.columns
pin_comments_df = pin_comments_df.reset_index()
pin_comments_df
max(pin_comments_df["comments_no"])
min(pin_comments_df["comments_no"])
Jetzt kann die Visualisierung erzeugt werden, hier ein Balkendiagramm:
import matplotlib.pyplot as plt
# Gesamter Link wird auf der x-Achse angezeigt
pin_comments_df.plot(kind="bar", x="link", y="comments_no", legend=False)
plt.xlabel("Link to Pin")
plt.ylabel("Number of Comments")
plt.title("Comments per Pin")
plt.show()
Diese Visualisierung ist jedoch nicht besonder gut lesbar, da die Links auf der X-Achse sehr lang sind. Wir erstellen deswegen eine neue Spalte pin_id, welche nur die ID des jeweiligen Pins enthält, und erstellen die Visualisierung dann erneut:
# Neue Spalte pin_id erstellen, damit nur Pin-Nummer auf der x-Achse angezeigt werden kann
pin_comments_df["pin_id"] = pin_comments_df['link'].str.extract('(\d+)')
pin_comments_df
# Pin-ID auf der x-Achse anzeigen
pin_comments_df.plot(kind="bar", x="pin_id", y="comments_no", legend=False)
plt.xlabel("Pin ID")
plt.ylabel("Anzahl Kommentare")
plt.title("Kommentare je Pin")
plt.show()
Als nächstes interessiert uns, wie lang die Kommentare unter den beiden Pins sind. Um die Länge der Kommentare für die beiden Pins zu vergleichen, wollen wir einen Boxplot erstellen. Bevor die Visualisierung erstellt werden kann, muss jedoch erst wieder eine neue Spalte mit der Länge der Kommentare hinzugefügt werden:
# Länge der Kommentare bestimmen und neue Spalte comment_length erstellen
length_per_comment = comments_df.groupby(by="commentator_id")["comment"]
length_per_comment
# Länge der Kommentare bestimmen und neue Spalte comment_length erstellen
comments_df["comment_length"] = comments_df["comment"].apply(len)
# Fehlermeldung: hier wäre es doch besser, wir hätten die leeren Zeichenketten anstelle der NA-Werte gelassen
Beim Versuch die Funktion len() auf die Einträge in der Spalte comment anzuwenden, bekommen wir eine Fehlermeldung, da wir zuvor leere Strings durch NA-Werte ausgetauscht haben. Das stellt sich also jetzt als voreilig heraus, und wir machen die Operation zunächst rückgängig:
# NA Werte durch leere Zeichenkette ersetzen
comments_df.replace(pd.NA, "", inplace=True)
# Länge der Kommentare bestimmen und neue Spalte comment_length erstellen
comments_df["comment_length"] = comments_df["comment"].apply(len)
comments_df
Jetzt können wir den Boxplot erstellen. Dazu verwenden wir die Bibliothek Seaborn, da diese die Erstellung von komplexeren Grafiken erleichtert:
import seaborn as sns
# Dokumentation zur Funktion catplot(): https://seaborn.pydata.org/generated/seaborn.catplot.html#seaborn.catplot
sns.catplot(data=comments_df, x="link", y="comment_length", kind="box")
Wir haben hier dasselbe Problem, auf das wir auch beim Erzeugen des Balkendiagramms mit Matplotlib gestoßen sind: die langen URLs eignen sich nicht gut zur Beschriftung der x-Achse. Wir erstellen deswegen wieder eine neue Spalte, die nur die Pin-IDs enthält und erstellen dann die Grafik erneut:
# Neue Spalte pin_id erstellen, damit nur Pin-Nummer auf der x-Achse angezeigt werden kann
comments_df["pin_id"] = comments_df['link'].str.extract('(\d+)')
comments_df
sns.catplot(data=comments_df, x="pin_id", y="comment_length", kind="box")
Wenn auf der x-Achse mehrere längere Wörter stehen müssen, kann die Beschriftung der x-Achse zum Beispiel um 90 Grad rotiert werden:
sns.catplot(data=comments_df, x="pin_id", y="comment_length", kind="box").set_xticklabels(rotation=90)
10.1.5. Quellen#
Liam Brannigan. Cheatsheet for Pandas to Polars. 2024. URL: https://www.rhosignal.com/posts/polars-pandas-cheatsheet/.
Jodie Burchell. Polars vs. Pandas: What's the Difference? 2023. URL: https://blog.jetbrains.com/dataspell/2023/08/polars-vs-pandas-what-s-the-difference/.
Damien Dotta. Cookbook Polars for R. 2023. URL: https://ddotta.github.io/cookbook-rpolars/.
Matplotlib. Matplotlib 3.8 Documentation: matplotlib.pyplot.yticks. 2023. URL: https://pandas.pydata.org/docs/reference/series.html.
Wes McKinney. Data Wrangling with Pandas Cheat Sheet. 2024. URL: https://pandas.pydata.org/Pandas_Cheat_Sheet.pdf.
Wes McKinney. Pandas 2.2 Documentation: DataFrame. 2024. URL: https://pandas.pydata.org/docs/reference/frame.html.
Wes McKinney. Pandas 2.2 Documentation: Getting Started Tutorials. 2024. URL: https://pandas.pydata.org/docs/getting_started/intro_tutorials/index.html.
Wes McKinney. Pandas 2.2 Documentation: IO Tools. 2024. URL: https://pandas.pydata.org/docs/user_guide/io.html.
Wes McKinney. Pandas 2.2 Documentation: Series. 2024. URL: https://pandas.pydata.org/docs/reference/series.html.
Wes McKinney. Pandas 2.2 Documentation: Working with Missing Data. 2024. URL: https://pandas.pydata.org/docs/user_guide/missing_data.html.