18. Pandas#

Numpy ist das Python-Tool wenn es um den Umgang mit großen Zahlenmengen geht. Aber für viele Data Science-artige Aufgaben fehlen bestimmte Dinge. Und genau darum gibt es Pandas (das intern u.a. Numpy Arrays benutzt):

  • Heterogene Datentypen innerhalb einer Tabelle

  • Labels

  • Mehr Möglichkeiten zum Umgang mit fehlenden Werten

  • Mehr Datentypen

Pandas (der Name steht für Panel Data) wird üblicherweise wie folgt importiert:

import pandas as pd

In Numpy haben wir uns v.a. mit den Numpy Arrays beschäftigt, der zentrale Datentyp in Numpy.

In Pandas gibt es zwei Datentypen mit denen wir arbeiten werden: DataFrame und Series. Wir starten mit ersterem.

import pandas as pd
import numpy as np

my_dict = {
    "city": ["Essen", "Bochum", "Dortmund"],
    "inhabitants": [591032, 364454, 587696],
    "xyz": np.random.random(3)
}

df = pd.DataFrame(my_dict, index=["a", "b", "c"])

18.1. Jupyter Notebook#

Bisher haben wir unseren Python Code entweder in einer Konsole geschrieben (eigentlich nur test-weise), oder eben hauptsächlich in einer Entwicklungsumgebung wie Spyder. Letzteres ist und bleibt auch der bevorzugte Weg um umfangreicheren Code zu entwickeln.

Allerdings gibt es noch eine alternative Umgebung in der sehr gut mit Python gearbeitet werden kann und die sich besonders für Data Science Aufgaben, bzw. erste Erkundungen von Datensets eignet: Jupyter Notebook. Die ganzen folgenden Code-Beispiele werden darum auch in solchen Notebooks dargestellt.

Jupyter Notebook ist Teil der Anaconda-Installation und erzeugt Notebook-Dateien die mit .ipynb enden. Jupyter Notebook kann über das Startmenü (unter Anaconda) gestartet werden, oder über den Befehl jupyter notebook aus dem Terminal.

In der Vorlesung wird die Verwendung von Jupyter notebooks vorgeführt und wir werden uns in der nächsten Übung auch damit beschäftigen. Ansonsten gibt es im Internet viel Material zur Benutzung der notebooks, z.B. hier: https://realpython.com/jupyter-notebook-introduction/.

import pandas as pd
import numpy as np

my_list = [
    ["Essen", 591032, np.random.random(1)[0]],
    ["Bochum", 364454, np.random.random(1)[0]],
    ["Dortmund", 587696, np.random.random(1)[0]]
]

df = pd.DataFrame(
    my_list,
    columns=["city", "inhabitants", "xyz"],
    index=["a", "b", "c"]
)

18.2. Slicing und Indexing#

Slicing und Indexing, also das gezielte „zerschneiden“ (slicing) und Adressieren (indexing) von Tabellen um nur bestimmte Daten wiederzugeben ist eines der zentralen Elemente bei der Arbeit mit größeren Datenmengen. Bei Numpy haben wir schon gesehen, wie mächtig, aber auch wie schwierig das sein kann. In Pandas gibt es sogar oft noch mehr Optionen und es kann schnell etwas unübersichtlich werden. Allerdings: Gute Slicing/Indexing-Skills in Pandas werden ziemlich schnell zu echten Superkräften wenn es um Data Science geht!

OK, aber starten wir erstmal einfach.

Wir haben gerade gesehen, dass ein Dictionary in ein DataFrame umgesetzt werden kann. Und die Inhalte können auch auf eine Ähnliche Art abgerufen werden, nämlich über die Spaltennamen (entweder einzeln oder als Liste):

df["inhabitants"]

cols = ["city", "inhabitants"]
df[cols]
city inhabitants
a Essen 591032
b Bochum 364454
c Dortmund 587696

Um bestimmte Zeilen auszuwählen ist es möglich .loc zu nutzen, also .loc["a"] für eine Zeile oder eine Liste oder Range für mehrere Zeilen

df = pd.DataFrame({
    "city": ["Essen", "Bochum", "Dortmund"],
    "inhabitants": [591032, 364454, 587696],
    "xyz": np.random.random(3)},
    index=["a", "b", "c"])
                  
df.loc["a"]

# oder über liste
rows = ["a", "c"]
df.loc[rows]

# oder über range mit :
df.loc["a":"b"]

# Das kann auch mit Spalten kombiniert werden
df.loc["a", ["city", "inhabitants"]]
city            Essen
inhabitants    591032
Name: a, dtype: object

Alternativ gibt es noch einen Weg über die Indices und zwar mit .iloc und das sieht dann plötzlich wieder aus wie bei Numpy:

df.iloc[0:2]
city inhabitants xyz
a Essen 591032 0.711240
b Bochum 364454 0.136874
# oder auch Zeilen und Spalten
df.iloc[0:2, 1:]
inhabitants xyz
a 591032 0.711240
b 364454 0.136874

Zusammenfassung:

  • [ ] um Spalten zu wählen

  • .loc[row_labels, column_labels] um über die Labels (Zeilen und Spaltennamen) Elemente auszuwählen

  • .iloc[row_positions, column_positions] um über die Indices Elemente auszuwählen

18.2.1. Elemente hinzufügen/entfernen#

Einem DataFrame können weitere Spalten genau wie bei Dictionaries hinzugefügt werden:

df["height"] = [116, 100, 86]
df
city inhabitants xyz height
a Essen 591032 0.711240 116
b Bochum 364454 0.136874 100
c Dortmund 587696 0.996618 86

Der umgekehrte Schritt wäre, eine Spalte komplett zu entfernen, das geht mit del df["xyz"].

18.2.2. Now: go Data Science#

Bisher haben wir uns nur einfache Beispiele angeschaut um die Methoden von Pandas ein wenig kennenzulernen. Interessant wird das Ganze aber eigentlich erst wenn wir anfangen mit größeren Datensets zu spielen. Wann ein Datenset als „groß“ gilt ist natürlich relativ, hier meine ich damit v.a. Daten die zumindest so groß sind, dass wir sie nicht mit einem kurzem Blick auf eine Tabelle vollständig verstehen können.

Für diesen Zweck fangen wir hier an mit dem „Pokemon Dataset“ zu arbeiten das man auf „Kaggle“ herunterladen kann: https://www.kaggle.com/rounakbanik/pokemon

18.2.3. Daten importieren#

Das Laden von Daten aus Textdateien geht in der Regel recht einfach mit Pandas, da Pandas entsprechende Funktionen dafür bereitstellt. U.a. zum importieren von CSV oder Excel-Dateien.

import os

root = os.getcwd()
filename = os.path.join(root, "../data", 'pokemon.csv')  # je nach eigenem Pfad anpassen!

data = pd.read_csv(filename, delimiter=",")
# hier geht auch: data = pd.read_csv(filename) da delimiter="," der "default"-Parameter ist

data.head()
abilities against_bug against_dark against_dragon against_electric against_fairy against_fight against_fire against_flying against_ghost ... percentage_male pokedex_number sp_attack sp_defense speed type1 type2 weight_kg generation is_legendary
0 ['Overgrow', 'Chlorophyll'] 1.0 1.0 1.0 0.5 0.5 0.5 2.0 2.0 1.0 ... 88.1 1 65 65 45 grass poison 6.9 1 0
1 ['Overgrow', 'Chlorophyll'] 1.0 1.0 1.0 0.5 0.5 0.5 2.0 2.0 1.0 ... 88.1 2 80 80 60 grass poison 13.0 1 0
2 ['Overgrow', 'Chlorophyll'] 1.0 1.0 1.0 0.5 0.5 0.5 2.0 2.0 1.0 ... 88.1 3 122 120 80 grass poison 100.0 1 0
3 ['Blaze', 'Solar Power'] 0.5 1.0 1.0 1.0 0.5 1.0 0.5 1.0 1.0 ... 88.1 4 60 50 65 fire NaN 8.5 1 0
4 ['Blaze', 'Solar Power'] 0.5 1.0 1.0 1.0 0.5 1.0 0.5 1.0 1.0 ... 88.1 5 80 65 80 fire NaN 19.0 1 0

5 rows × 41 columns

# oder wir schauen die letzten Zeilen zuerst an
data.tail()
abilities against_bug against_dark against_dragon against_electric against_fairy against_fight against_fire against_flying against_ghost ... percentage_male pokedex_number sp_attack sp_defense speed type1 type2 weight_kg generation is_legendary
796 ['Beast Boost'] 0.25 1.0 0.5 2.0 0.5 1.0 2.0 0.5 1.0 ... NaN 797 107 101 61 steel flying 999.9 7 1
797 ['Beast Boost'] 1.00 1.0 0.5 0.5 0.5 2.0 4.0 1.0 1.0 ... NaN 798 59 31 109 grass steel 0.1 7 1
798 ['Beast Boost'] 2.00 0.5 2.0 0.5 4.0 2.0 0.5 1.0 0.5 ... NaN 799 97 53 43 dark dragon 888.0 7 1
799 ['Prism Armor'] 2.00 2.0 1.0 1.0 1.0 0.5 1.0 1.0 2.0 ... NaN 800 127 89 79 psychic NaN 230.0 7 1
800 ['Soul-Heart'] 0.25 0.5 0.0 1.0 0.5 1.0 2.0 0.5 1.0 ... NaN 801 130 115 65 steel fairy 80.5 7 1

5 rows × 41 columns

Die Tabelle ist hier erstmal zu groß, darum können wir uns erst die Spaltennamen anschauen:

data.columns
Index(['abilities', 'against_bug', 'against_dark', 'against_dragon',
       'against_electric', 'against_fairy', 'against_fight', 'against_fire',
       'against_flying', 'against_ghost', 'against_grass', 'against_ground',
       'against_ice', 'against_normal', 'against_poison', 'against_psychic',
       'against_rock', 'against_steel', 'against_water', 'attack',
       'base_egg_steps', 'base_happiness', 'base_total', 'capture_rate',
       'classfication', 'defense', 'experience_growth', 'height_m', 'hp',
       'japanese_name', 'name', 'percentage_male', 'pokedex_number',
       'sp_attack', 'sp_defense', 'speed', 'type1', 'type2', 'weight_kg',
       'generation', 'is_legendary'],
      dtype='object')

18.2.3.1. Neues, kleineres DataFrame erzeugen#

Das sind ziemlich viele Spalten! Wir nehmen darum erstmal nur ein paar davon für die nächsten Schritte.

cols = [
    'name', 'type1', 'type2',
    'speed', 'abilities', 'attack',
    'defense', 'height_m', 'hp', 'weight_kg',
    'generation', 'is_legendary'
]

data_selected = data[cols]
data_selected.head()
name type1 type2 speed abilities attack defense height_m hp weight_kg generation is_legendary
0 Bulbasaur grass poison 45 ['Overgrow', 'Chlorophyll'] 49 49 0.7 45 6.9 1 0
1 Ivysaur grass poison 60 ['Overgrow', 'Chlorophyll'] 62 63 1.0 60 13.0 1 0
2 Venusaur grass poison 80 ['Overgrow', 'Chlorophyll'] 100 123 2.0 80 100.0 1 0
3 Charmander fire NaN 65 ['Blaze', 'Solar Power'] 52 43 0.6 39 8.5 1 0
4 Charmeleon fire NaN 80 ['Blaze', 'Solar Power'] 64 58 1.1 58 19.0 1 0

Einen ersten Eindruck der Daten (zumindest der numerischen Einträge), können wir uns verschaffen über:

data_selected.describe()
speed attack defense height_m hp weight_kg generation is_legendary
count 801.000000 801.000000 801.000000 781.000000 801.000000 781.000000 801.000000 801.000000
mean 66.334582 77.857678 73.008739 1.163892 68.958801 61.378105 3.690387 0.087391
std 28.907662 32.158820 30.769159 1.080326 26.576015 109.354766 1.930420 0.282583
min 5.000000 5.000000 5.000000 0.100000 1.000000 0.100000 1.000000 0.000000
25% 45.000000 55.000000 50.000000 0.600000 50.000000 9.000000 2.000000 0.000000
50% 65.000000 75.000000 70.000000 1.000000 65.000000 27.300000 4.000000 0.000000
75% 85.000000 100.000000 90.000000 1.500000 80.000000 64.800000 5.000000 0.000000
max 180.000000 185.000000 230.000000 14.500000 255.000000 999.900000 7.000000 1.000000

Danach kommen wir zum eigentlichen Kern der Pandas Analyse. Durch das Kombinieren von Slicing/Indexing und weiteren Pandas-Methoden können verschiedene Aspekte der Daten schnell und gut erkundet werden.

Z.B um zu schauen welche Typen von Pokemon es gibt:

data_selected["type1"].unique()
array(['grass', 'fire', 'water', 'bug', 'normal', 'poison', 'electric',
       'ground', 'fairy', 'fighting', 'psychic', 'rock', 'ghost', 'ice',
       'dragon', 'dark', 'steel', 'flying'], dtype=object)

Oder um zu schauen, wie viele es von jedem Typ gibt:

data_selected["type1"].value_counts()
type1
water       114
normal      105
grass        78
bug          72
psychic      53
fire         52
rock         45
electric     39
poison       32
ground       32
dark         29
fighting     28
dragon       27
ghost        27
steel        24
ice          23
fairy        18
flying        3
Name: count, dtype: int64

18.2.4. Wie bekomme ich nun die Werte#

Pandas gibt in den meisten Fällen entweder ein DataFrame (Tabelle) zurück, oder eine Series (eine einzelne Spalte mit Zeilennamen). In manchen Fällen wollen wir aber nur die eigentlichen Werte aus der Spalte (oder aus der Tabelle) haben. Dafür gibt es in Pandas die Möglichkeit ein Numpy Array auszugeben mit .values.

values = data_selected["type1"].value_counts().values
print(values)
print(type(values))
[114 105  78  72  53  52  45  39  32  32  29  28  27  27  24  23  18   3]
<class 'numpy.ndarray'>

18.3. Sortieren#

Anders als by Numpy und Listen gibt es bei Pandas keine Methode .sort(), sondern zwei verschiedene Methoden:

  • .sort_index() - Sortiert nach dem Index

  • .sort_values() - Sortiert nach Spaltenwerten

Anders als bei Listen gibt es kein Parameter „reverse“ sondern dafür ascending, das zur Umkehrung der Sortierung auf False gesetzte werden kann.

data_selected.sort_values(["speed", "name"])
name type1 type2 speed abilities attack defense height_m hp weight_kg generation is_legendary
445 Munchlax normal NaN 5 ['Pickup', 'Thick Fat', 'Gluttony'] 85 40 0.6 135 105.0 4 0
770 Pyukumuku water NaN 5 ['Innards Out', 'Unaware'] 60 130 0.3 55 1.2 7 0
212 Shuckle bug rock 5 ['Sturdy', 'Gluttony', 'Contrary'] 10 230 0.6 20 20.5 2 0
437 Bonsly rock NaN 10 ['Sturdy', 'Rock Head', 'Rattled'] 80 95 0.5 50 15.0 4 0
596 Ferroseed grass steel 10 ['Iron Barbs'] 50 91 0.6 44 18.8 5 0
... ... ... ... ... ... ... ... ... ... ... ... ...
64 Alakazam psychic NaN 150 ['Synchronize', 'Inner Focus', 'Magic Guard'] 50 65 1.5 55 48.0 1 0
100 Electrode electric NaN 150 ['Soundproof', 'Static', 'Aftermath'] 50 70 1.2 60 66.6 1 0
794 Pheromosa bug fighting 151 ['Beast Boost'] 137 37 1.8 71 25.0 7 1
290 Ninjask bug flying 160 ['Speed Boost', 'Infiltrator'] 90 45 0.8 61 12.0 3 0
385 Deoxys psychic NaN 180 ['Pressure'] 95 90 1.7 50 60.8 3 1

801 rows × 12 columns

data_selected.sort_values(["speed", "name"], ascending=False)
name type1 type2 speed abilities attack defense height_m hp weight_kg generation is_legendary
385 Deoxys psychic NaN 180 ['Pressure'] 95 90 1.7 50 60.8 3 1
290 Ninjask bug flying 160 ['Speed Boost', 'Infiltrator'] 90 45 0.8 61 12.0 3 0
794 Pheromosa bug fighting 151 ['Beast Boost'] 137 37 1.8 71 25.0 7 1
100 Electrode electric NaN 150 ['Soundproof', 'Static', 'Aftermath'] 50 70 1.2 60 66.6 1 0
64 Alakazam psychic NaN 150 ['Synchronize', 'Inner Focus', 'Magic Guard'] 50 65 1.5 55 48.0 1 0
... ... ... ... ... ... ... ... ... ... ... ... ...
596 Ferroseed grass steel 10 ['Iron Barbs'] 50 91 0.6 44 18.8 5 0
437 Bonsly rock NaN 10 ['Sturdy', 'Rock Head', 'Rattled'] 80 95 0.5 50 15.0 4 0
212 Shuckle bug rock 5 ['Sturdy', 'Gluttony', 'Contrary'] 10 230 0.6 20 20.5 2 0
770 Pyukumuku water NaN 5 ['Innards Out', 'Unaware'] 60 130 0.3 55 1.2 7 0
445 Munchlax normal NaN 5 ['Pickup', 'Thick Fat', 'Gluttony'] 85 40 0.6 135 105.0 4 0

801 rows × 12 columns