Der Datentyp ‚Categorial‘ in pandas

In diesem Beitrag geht es um den in der Bibliothek pandas implementierten Datentyp Categorial. Wofür dieser entworfen wurde und wie er implementiert ist, wird im Folgenden diskutiert.

Inhalt

Warum ein Datentyp ‚Categorial‘?

Um die Frage zu beantworten warum pandas den Datentyp categorial einführt, müssen wir uns zunächst auch die Frage stellen, welchen Zweck pandas in der Python Systemlandschaft einnimmt. Ganz allgemein gesprochen ist pandas eine Bibliothek für das Datenmanagement. Mit den Datentypen Series und DataFrame ist die Datenhaltung gegenüber den nativen Python-Datentypen wie Listen und Dictionaries optimiert. In seinem Kern zielt Pandas auf das Datenmanagement von relationalen Daten – also Daten die in rechteckiger Form (sprich: Tabellen) vorliegen. Dabei unterstützt der DataFrame die klassische Vorstellung von Statistikern oder Analysten, wie Daten auszusehen haben. Für diese liegen Daten in 2-dimensionaler Tabellenform vor. Jede Zeile der Tabelle repräsentiert ein Beobachtungsobjekt. Jede Spalte enthält die Information über ein Attribut für alle Beobachtungsobjekte.

Schlagen wir nun noch eine weitere Brücke zur Statistik. Dort werden grundsätzlich diese 3 Datentypen unterschieden:

  • Metrische Daten (Messdaten jeder Art wie Alter, Größe, Gewicht)
  • Ordinale Daten (Daten mit natürlicher Ordnung wie Schulnoten, Dienstgrade beim Militär, (Likert-) Skalenangaben)
  • Nominale Daten (Daten ohne natürliche Ordnung wie Geschlecht, Lieblingsfarbe, Kontonummer, Postleitzahlen)

Metrische Daten lassen sich in Python durch die Datentypen Integer oder Float repräsentierten – und diese sind natürlich auch in Pandas integriert, d.h. eine Series kann einen solchen Typ annehmen. Die Darstellung oder Repräsentation von ordinalen und nominalen Daten kann verschieden erfolgen, je nach Information, die gespeichert werden soll. Schulnoten oder Kontonummern könnten wie metrische Daten als Integer abgelegt werden. Dienstgrade beim Militär oder das Geschlecht könnten dagegen als String gespeichert sein.

Je nach Repräsentation von ordinaler oder nominaler Information ergeben sich jedoch Probleme oder Gefahren.

  • Legen wir bspw. Postleitzahlen als Integer ab, könnten wir technisch gesehen an einem solchen Objekt arithmetische Operationen durchführen (wie einen Mittelwert berechnen), die inhaltlich keinen Sinn ergeben. Die Postleitzahlen als String abzulegen wäre eine Alternative – allerdings eine Kostenintensive, da für einen String wesentlich mehr Speicher benötigt wird als für einen Integer. Außerdem, und dies ist ein weiterer technischer Aspekt, dem der Datentyp Categorial Rechnung trägt, gibt es nur eine endliche, sehr begrenzte Menge von Postleitzahlen. Technisch möchte man womöglich vermeiden, dass falsche (also nicht existente Postleitzahlen) Einträge vorgenommen werden.
# --- Postleitzahlen
import pandas as pd

plz = pd.Series([34131,11124,41556,22134,47432,85563], index=['Alex','Julia','Lena','Max','Jens','Ole'])
plz.mean() # !!!Dieser Mittelwert ergibt keinen Sinn, auch wenn er sich berechnen lässt!!!


# --- Vergleich der Speicherintensität - Postleitzahlen als Integer bzw. String
import sys
sys.getsizeof(plz) # Größe des Objekts in Bytes
# >>> 437

plz_string = pd.Series(['34131','11124','41556','22134','47432','85563'], index=['Alex','Julia','Lena','Max','Jens','Ole'])
sys.getsizeof(plz_string) 
# >>> 761
  • Halten wir bspw. die Information über das Geschlecht als ein String-Objekt, ist dieses (wie im Beispiel der Postleitzahlen gesehen) ebenfalls sehr speicherintensiv. Die gleiche Information (also das Geschlecht) könnte auch als 0 (Männlich) und 1 (Weiblich) codiert sein – ebenfalls als Integer also. Wir müssen bei einer Integerablage der Information allerdings sicherstellen, dass irgendwo ein entsprechendes Mapping (0=Männlich und 1=Weiblich) vorhanden ist um eine falsche Zuordnung zu vermeiden. Wie im Beispiel der Postleitzahlen gilt darüber hinaus auch für das Geschlecht – es existiert eine begrenzte Menge möglicher Ausprägungen. In dem Beispiel genau zwei: Männlich und Weiblich. Da keine weiteren Ausprägungen denkbar, sind sollen solche (falsche) Eintragungen möglicherweise vermieden werden.
# --- Speicherintensive Ablage der Information 'Geschlecht'
geschlecht = pd.Series(['Männlich','Weiblich','Weiblich','Männlich','Männlich','Männlich'], index=['Alex','Julia','Lena','Max','Jens','Ole'])

sys.getsizeof(geschlecht)
# >>> 875

# --- Effiziente Ablage der Information 'Geschlecht' mit entsprechendem Mapping-Objekt                        
geschlecht = pd.Series([0,1,1,0,0,1], index=['Alex','Julia','Lena','Max','Jens','Ole'])
mapper_geschlecht = {0:'Männlich', 1:'Weiblich'}

sys.getsizeof(geschlecht)
# >>> 437

sys.getsizeof(mapper_geschlecht) # Berechnen wir fairerweise noch die Größe des Mappers
# >>> 240

# Die Summe der Bytes von den Objekten geschlecht und mapper_geschlecht benötigen wir um alle 
# Informationen zu Speichern 
437 + 240
# >>> 677

All den genannten Aspekten: Speichereffizienz, Vermeidung von nicht sinnvollen arithmetischen Operationen, Notwendigkeit von Mapping und Vermeidung von Falscheinträgen trägt der Datentyp Categorial aus pandas Rechnung.

Die Implementierung in pandas

Lasst uns zunächst eine Series mit dem Datentyp Category erstellen, um mehr über dessen Implementierung zu erfahren.

In [119]:
geschlecht_category = pd.Series(['Männlich','Weiblich','Weiblich','Männlich','Männlich','Männlich'], 
                       index=['Alex','Julia','Lena','Max','Jens','Ole'],
                      dtype="category")
print(geschlecht)
Alex     Männlich
Julia    Weiblich
Lena     Weiblich
Max      Männlich
Jens     Männlich
Ole      Männlich
dtype: category
Categories (2, object): [Männlich, Weiblich]
<class 'pandas.core.arrays.categorical.CategoricalAccessor'>

Wie wir am Code sehen, übergeben wir dem Konstruktor der Klasse Series den dtype ‚category‘ (nicht ‚categorial‘ – was etwas kontraintuitiv ist), um den entsprechenden Datentyp für die Series zu erhalten. Die Ausgabe von print zeigt uns neben den Einträgen in der Series, auch die am Objekt angehangenen Kategorien an.

Wenn wir uns die Größe des Objekts ausgeben, sehen wir, dass diese ziemlich genau der Anzahl von Bytes entspricht, die die Summe der Objekte geschlecht und mapper_geschlecht im obigen Beispiel ergeben haben.

sys.getsizeof(geschlecht_category)
# >>> 675

Tatsächlich enthält die Series auch genau diese Informationen: Die Ausprägungen ‚Männlich‘ und ‚Weiblich‘ als Integerrepäsentationen im Speicher und ein entsprechendes Mapping. Dieses Mapping wird unter anderem von der print-Anweisung verwendet, um dem Anwender mit ‚Männlich‘ und ‚Weiblich‘ die sog. Labels der Ausprägungen anzuzeigen. Damit hat der Analyst einen schnelleren Zugang zu den Daten um muss das Mapping nicht mehr selbst (in seinem Kopf 🙂 ) vornehmen. Aber nochmals: Im Speicher sind die Werte als Integer repräsentiert und somit effizient abgelegt. Im Grunde beweist uns print, dass ein Mapping bereits am Objekt vorhanden ist.

Nun gut, aber wenn die Werte im Speicher als Integer abgelgt sind, lassen sich dann arithmetische Operationen an ihnen ausführen?

geschlecht_category.mean()
# >>> TypeError: Categorical cannot perform the operation mean

Die Antwort: Nein! Der Typ ist von arithmetischen Operationen geschützt.

Und schauen wir uns nun an, was passiert, wenn wir einen Eintrag vornehmen wollen, der nicht in den ‚Categories‘ enthalten ist.

geschlecht_category[3] = 'Drittes Geschlecht'
# >>> ValueError: Cannot setitem on a Categorical with a new category, set the categories first

Wir sehen, dass die Ausprägung nicht erlaubt ist. Die Fehlermeldung weist uns an, zunächt die entsprechende Kategorie in die Liste von zulässigen Kategorien aufzunehmen. Dies können wir über die Methode add_categories tun, welche am Attribut cat der Series gebunden ist. Bei cat handelt es sich um den sog. CategoricalAccessor – dies ist das Objekt an dem die Implementierung von Categorie hängt.

geschlecht_category.cat.add_categories(["Drittes Geschlecht"], inplace = True)
geschlecht_category.cat.categories
# >>> Index(['Männlich', 'Weiblich', 'Drittes Geschlecht'], dtype='object')

# --- Jetzt können wir die Ausprägung "Drittes Geschlecht" zuweisen
geschlecht_category[3] = 'Drittes Geschlecht'

# Um eine Kategorie zu entfernen existiert remove_categories
# geschlecht_category.cat.remove_categories('Drittes Geschlecht')

Über das Attribut cat kann auch die Integerrepräsentation der Daten abgerufen werden.

In [109]:
geschlecht_category.cat.codes
Out[109]:
Alex     0
Julia    1
Lena     1
Max      0
Jens     0
Ole      0
dtype: int8

Sortierung von kategorialen Daten

Nun noch zu einem weiteren Aspekt. Oben haben wir festgehalten, dass statistische Daten metrischer, ordinaler oder kategorialer Natur sein können. Wenn wir über die Information Geschlecht reden, dann reden wir über kategoriale Daten (nebenbei bemerkt: Es macht ja augenscheinlich Sinn, den Datentyp ‚Category‘ zu nennen, wenn dieser kategoriale Daten speichern soll).

Naturgemäß lassen sich kategoriale Daten zwar ‚irgendwie‘ sortieren, allerdings unterliegt die Sortierung keiner hierarchischen Struktur – es gibt also keine natürliche Ordnung des Geschlechts (oder der Lieblingsfarbe oder dem Wetter).

Führen wir die Methode sort_values aus, werden die Daten anhand der zugeordneten Codes (also der Integerrepräsentationen) sortiert.

In [110]:
geschlecht_category.sort_values()
Out[110]:
Alex     Männlich
Max      Männlich
Jens     Männlich
Ole      Männlich
Julia    Weiblich
Lena     Weiblich
dtype: category
Categories (2, object): [Männlich, Weiblich]

Das Mapping erfolgte dabei nachfolgend einer alphabetischen Sortierung der Ausprägungen bei der Objekterstellung. Unten sehen wir das Vorgehen an einem Beispiel. Sowohl die Series ‚ABC‘ als auch ‚CBA‘ erhalten das Mapping {‚A‘:0,’B‘:1,’C‘:2}.

In [111]:
test = pd.Series(list('ABC'), dtype='category') 
test.cat.categories
test.cat.codes
Out[111]:
0    0
1    1
2    2
dtype: int8
In [112]:
test = pd.Series(list('CBA'), dtype='category') 
test.cat.categories
test.cat.codes
Out[112]:
0    2
1    1
2    0
dtype: int8

Die Klasse Categorial

Wir haben in den bisherigen Beispielen eine Series des Typs ‚Categorial‘ mit dem Konstruktor der Klasse Series erstellt. Darüber hinaus ermöglicht es uns pandas auch, mit Hilfe einer Klasse ‚Categorial‘ eine solche Series zu erstellen – und dieses Vorgehen hat 2 entscheidendende Vorteile. Zum einem kann die Menge der gewünschten Kategorien unmittelbar bei der Erstellung definiert werden. Gemäß dieser Definition werden fehlerhafte Einträge dadurch auf NaN gesetzt. Zum anderen lässt sich die Sortierung des Mapping manuell vornehmen. In-Order-Of-Appearance wird den Werten der Liste für das Argument categories aufsteigende Integerwerte zugewiesen. NaN-Werte erhalten den Code -1.

In [113]:
geschlecht_category = pd.Categorical(['Männlich','Weiblich','Weiblich','Drittes Geschlecht','Männlich','Männlich'], 
                                     categories=['Weiblich','Männlich'])
geschlecht_category
Out[113]:
[Männlich, Weiblich, Weiblich, NaN, Männlich, Männlich]
Categories (2, object): [Weiblich, Männlich]
In [114]:
# --- Codes der Klasse Categorial
geschlecht_category.codes
Out[114]:
array([ 1,  0,  0, -1,  1,  1], dtype=int8)

Anmerkung: Die Klasse Categorial verfügt anders als eine Series über keinen Indexwert. Um den Daten einen Indexwert zuzuweisen, muss eine Series erstellt werden:

pd.Series(geschlecht_category, index = ['Alex','Julia','Lena','Max','Jens','Ole'])

Ordinale Daten in Categorial

Der Datentyp Categorial enthält ein Feature um ordinale Daten abzulegen. Unten das Beispiel Schulnoten:

In [115]:
noten = pd.Categorical(values = ['Gut','Mangelhaft','Befriedigend','Ausreichend','Befriedigend','Sehr gut'], 
                       categories=['Sehr gut','Gut','Befriedigend','Ausreichend','Mangelhaft','Ungenügend'],
                      ordered=True)
noten
Out[115]:
[Gut, Mangelhaft, Befriedigend, Ausreichend, Befriedigend, Sehr gut]
Categories (6, object): [Sehr gut < Gut < Befriedigend < Ausreichend < Mangelhaft < Ungenügend]

Wir sehen bereits an der Ausgabe von dem Objekt noten, dass die Kategorien mit einem ‚<‚-Zeichen versehen. Dies repräsentiert die Hierarchie der Daten.

Integer zu Categorial

Weil er in der Praxis so häufig anzutreffen ist, soll noch abschließend dieser Fall diskutiert werden: Es liegen Informationen als Integerrepräsentationen vor (beispielsweise die Schulnoten) und nun sollen diese in den Datentyp Categorial umgewandelt werden. Für ein solches Szenario wird die Methode rename_categories verwendet.

In [122]:
noten = pd.Categorical(values = [2,5,3,4,3,1], categories=[1,2,3,4,5,6])
noten.rename_categories({1:'Sehr gut', 2:'Gut',3:'Befriedigend',4:'Ausreichend',5:'Mangelhaft',6:'Ungenügend'})
Out[122]:
[Gut, Mangelhaft, Befriedigend, Ausreichend, Befriedigend, Sehr gut]
Categories (6, object): [Sehr gut, Gut, Befriedigend, Ausreichend, Mangelhaft, Ungenügend]

Fazit

Mit dem Datentyp Categorial ermöglicht es pandas ordinale und nominale Daten speichereffizient und sicher abzulegen. Der User erhält einen leichten Zugang zu den Daten, da die Konsolenausgabe des print-Befehls immer die Label der Kategorien ausgibt. Der Speicher wird durch den Datentyp nicht unnötig belastet, da dieser stets Integerrepräsentationen der Informationen enthält.