11.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.

11.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()?

11.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.common.selenium_manager import SeleniumManager
# from selenium.webdriver.common.by import By
# 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/"]
# driver = webdriver.Chrome()
  
# for link in tierlinks:
#     driver.get(link)
#     driver.implicitly_wait(10)
    
#     comment_elems = driver.find_elements(By.XPATH, "//div[@data-test-id='author-and-comment-container']/span/div/span/span[3]/span")    
#     commentator_elems = driver.find_elements(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 = 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"]

11.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)

# # 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.

11.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")

11.1.5. Quellen#

  1. Liam Brannigan. Cheatsheet for Pandas to Polars. 2024. URL: https://www.rhosignal.com/posts/polars-pandas-cheatsheet/.

  2. 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/.

  3. Damien Dotta. Cookbook Polars for R. 2023. URL: https://ddotta.github.io/cookbook-rpolars/.

  4. Matplotlib. Matplotlib 3.8 Documentation: matplotlib.pyplot.yticks. 2023. URL: https://pandas.pydata.org/docs/reference/series.html.

  5. Wes McKinney. Data Wrangling with Pandas Cheat Sheet. 2024. URL: https://pandas.pydata.org/Pandas_Cheat_Sheet.pdf.

  6. Wes McKinney. Pandas 2.2 Documentation: DataFrame. 2024. URL: https://pandas.pydata.org/docs/reference/frame.html.

  7. Wes McKinney. Pandas 2.2 Documentation: Getting Started Tutorials. 2024. URL: https://pandas.pydata.org/docs/getting_started/intro_tutorials/index.html.

  8. Wes McKinney. Pandas 2.2 Documentation: IO Tools. 2024. URL: https://pandas.pydata.org/docs/user_guide/io.html.

  9. Wes McKinney. Pandas 2.2 Documentation: Series. 2024. URL: https://pandas.pydata.org/docs/reference/series.html.

  10. Wes McKinney. Pandas 2.2 Documentation: Working with Missing Data. 2024. URL: https://pandas.pydata.org/docs/user_guide/missing_data.html.