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

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. Zunächst passen wir den Code aus der Übungsaufgabe so an, dass anstelle der Links zu den Fotos selbst die URLs der Fotoseiten extrahiert und als Set gespeichert werden. Wir verwenden hier ein Set, damit die Duplikate, welche während des Scrollens aufgrund des Ladeverhaltens der Pinterestseite (s. Übungsblatt) entstehen, automatisch entfernt werden.

# from selenium import webdriver
# from selenium.webdriver.common.selenium_manager import SeleniumManager
# from selenium.webdriver.common.by import By
# import pandas as pd
# import time

# driver = webdriver.Chrome()
# driver.get("https://www.pinterest.com/ideas/animals/925056443165/")
# time.sleep(5)
# scroll_pause_time = 3 # Drei Sekunde pausieren
# window_inner_height = driver.execute_script("return window.innerHeight;")
# i = 1
# tiere_links = set()

# while True:

#     # Um die Höhe eines Browserfensters scrollen
#     driver.execute_script(f"window.scrollTo(0, {window_inner_height}*{i});")
#     i += 1
#     # Kurze Zeit warten, damit die Seite nach jedem Scroll-Vorgang laden kann
#     time.sleep(scroll_pause_time)

#     # Daten extrahieren
#     tiere = driver.find_elements(By.CLASS_NAME, "Wk9.CCY.S9z.ho-.kVc.xQ4")
#     for tier in tiere:
#         pin_link = tier.get_attribute("href")
#         tiere_links.add(pin_link)

#     # Scroll-Höhe nach dem Scrollen aktualisieren, da sich die Scroll-Höhe nach dem Scrollen der Seite ändern kann
#     scroll_height = driver.execute_script("return document.body.scrollHeight;")

#     # Schleife beenden, wenn die Höhe, zu der wir scrollen müssen, größer ist als die gesamte Scroll-Höhe
#     if (window_inner_height) * i > scroll_height:
#         break

# time.sleep(5)
# tiere_links # Überprüfen

Anschließend werden von allen Tierfotoseiten die Kommentare sowie die Usernamen der Kommentator:innen extrahiert. Diese Daten speichern wir zusammen mit der URL zur Tierfotoseite zunächst als Python Dictionary, das wir zuletzt in einen Pandas Dataframe umwandeln:

# # Leeres Dictionary für die Kommentare anlegen
# comments_dict = {"link":[], "commentator":[], "comment":[]}

# for link in list(tiere_links)[1:4]: # erste drei Links
#     driver.get(link)
#     time.sleep(5)

#     comment_elems = driver.find_elements(By.XPATH, "//div[@data-test-id='author-and-comment-container']/span/div/span/span[3]")
#     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 bisher außerdem nur Kommentare von den ersten drei 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[1, "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"]

Den bereinigten und anonymisierten Dataframe speichern wir zuletzt 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.3. 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.