Binning in Python und Pandas

Einführung

Binning

Binning ist eine Technik, die in der Datenverarbeitung und Statistik verwendet wird. Unter Binning versteht man eine Klassenbildung in der Vorverarbeitung bei der Datenanalyse. Eine gegebene Menge von Werten, die sortiert sind, werden in Intervalle aufgeteilt. Diese Intervalle bezeichnet man im Englischen als "bins" (deutsch "Behälter"). Jedes dieser Intervalle (Bins) wird dann durch einen Repräsentanten bezeichnet. Man bezeichnet diese auch als Intervallabels. Binning wird häufig angewendet, wenn es mehr mögliche Daten gibt, als man eigentlich braucht. So können bspw. Körpergrössen von Menschen in Intervallen oder Kategorien eingeteilt werden.



Nehmen wir an, wir messen die Körpergrössen von 30 Menschen. Die Grössen-Werte können, grob geschätzt, zwischen 1,30 Meter und 2,50 Meter liegen. Theoretisch gibt es nun 120 verschiedene cm-Werte, jedoch werden wir meistens nur 30 verschiedene Werte aus der Test-Gruppe beobachten.

Eine Möglichkeit um sie zu gruppieren wäre die Einteilung in die Bereiche von 1,30 - 1,50 Meter, 1,50 - 1,70 Meter, 1,70 - 1,90 Meter, usw. Die Original-Daten werden also zu den passenden "Eimern" (Buckets) zugeordnet. Weiterhin werden die Original-Daten durch die korrespondierenden Intervalle ersetzt. Binning ist also eine Form der Quantifizierung.

Einteilungen (Bins) müssen nicht unbedingt numerisch sein. Die Kategorisierung kann bspw. von der Art "Hunde", "Katzen", "Hamster", usw. sein, also von jeder erdenklichen Art.

Binning wird auch in der Bildverarbeitung verwendet, um die Datenmengen zu reduzieren, indem benachbarte Pixel zu einzelnen Pixeln kombiniert werden. Man nennt das Verfahren auch kxk-binning, weil Bereiche von k x k Pixel auf einen Pixel reduziert werden.

Pandas bietet einfache Wege um Bins zu erstellen und so Daten einzuteilen. Bevor wir auf die Pandas-Funktionalität eingehen, möchten wir noch die Basis-Funktionen von Python vorstellen um Bins zu verwenden. Im folgenden Beispiel kommen Listen und Tupel zum Einsatz:

def create_bins(lower_bound, width, quantity):
    """ create_bins returns an equal-width (distance) partitioning. 
        It returns an ascending list of tuples, representing the 
        intervals.
        A tuple bins[i], i.e. (bins[i][0], bins[i][1])  with i > 0 
        and i < quantity, satisfies the following conditions:
            (1) bins[i][0] + width == bins[i][1]
            (2) bins[i-1][0] + width == bins[i][0] and
                bins[i-1][1] + width == bins[i][1]
    """
    
    bins = []
    for low in range(lower_bound, 
                     lower_bound + quantity * width + 1, width):
        bins.append((low, low+width))
    return bins

Wir erstellen nun 5 bins (quantity=5) mit einer Breite von 10 (width=10) und beginnen bei 10 (lower_bound=10):

bins = create_bins(lower_bound=10,
                   width=10,
                   quantity=5)
print(bins)
[(10, 20), (20, 30), (30, 40), (40, 50), (50, 60), (60, 70)]

Die nächste Funktion find_bin() wird mit einer Liste oder einem Tupel bins aufgerufen, welches 2er-Tupel oder Listen mit zwei Elementen enthalten muss. Die Funktion ermitteln den Index des Intervalls, der zu dem Wert value gehört:

def find_bin(value, bins):
    """ bins is a list of tuples, like [(0,20), (20, 40), (40, 60)],
        binning returns the smallest index i of bins so that
        bin[i][0] <= value < bin[i][1]
    """
    
    for i in range(0, len(bins)):
        if bins[i][0] <= value < bins[i][1]:
            return i
    return -1
from collections import Counter
bins = create_bins(lower_bound=50,
                   width=4,
                   quantity=10)
print(bins)
weights_of_persons = [73.4, 69.3, 64.9, 75.6, 74.9, 80.3, 
                      78.6, 84.1, 88.9, 90.3, 83.4, 69.3, 
                      52.4, 58.3, 67.4, 74.0, 89.3, 63.4]
binned_weights = []
for value in weights_of_persons:
    bin_index = find_bin(value, bins)
    print(value, bin_index, bins[bin_index])
    binned_weights.append(bin_index)
    
frequencies = Counter(binned_weights)
print(frequencies)
[(50, 54), (54, 58), (58, 62), (62, 66), (66, 70), (70, 74), (74, 78), (78, 82), (82, 86), (86, 90), (90, 94)]
73.4 5 (70, 74)
69.3 4 (66, 70)
64.9 3 (62, 66)
75.6 6 (74, 78)
74.9 6 (74, 78)
80.3 7 (78, 82)
78.6 7 (78, 82)
84.1 8 (82, 86)
88.9 9 (86, 90)
90.3 10 (90, 94)
83.4 8 (82, 86)
69.3 4 (66, 70)
52.4 0 (50, 54)
58.3 2 (58, 62)
67.4 4 (66, 70)
74.0 6 (74, 78)
89.3 9 (86, 90)
63.4 3 (62, 66)
Counter({4: 3, 6: 3, 3: 2, 7: 2, 8: 2, 9: 2, 5: 1, 10: 1, 0: 1, 2: 1})

Binning mit Pandas

Das Pandas-Modul bietet starke Funktionalitäten für die Einteilung von Daten. Wir demonstrieren dies anhand der vorigen Daten.

Von Pandas verwendete Bins

Als Bins haben wir im vorigen Beispiel eine Liste von Tupeln verwendet. Diese Liste müssen wir nun in eine Datenstruktur wandeln, welche von der Pandas-Funktion cut() verwendet werden kann. Diese Datenstruktur ist ein IntervalIndex. Wir können das mit dem Befehl pd.IntervalIndex.from_tuples() tun:

import pandas as pd
bins2 = pd.IntervalIndex.from_tuples(bins)

cut() ist der Name der Pandas-Funktion die wir brauchen, um Daten in Bins einzuteilen. cut() erwartet einige Parameter. Die wichtigsten sind aber xfür die aktuellen Werte und bins, welcher den IntervalIndex definiert. x kann jede eindimensionale Array-ähnliche Sruktur sein, wie z.B. Listen, Tupel, nd-arrays, usw.:

categorical_object = pd.cut(weights_of_persons, bins2)
print(categorical_object)
[(70, 74], (66, 70], (62, 66], (74, 78], (74, 78], ..., (58, 62], (66, 70], (70, 74], (86, 90], (62, 66]]
Length: 18
Categories (11, interval[int64]): [(50, 54] < (54, 58] < (58, 62] < (62, 66] ... (78, 82] < (82, 86] < (86, 90] < (90, 94]]

Das Ergebnis der Funktion cut() ist ein s.g. "Kategorisches Objekt (Categorical object)". Jedes "bin" entspricht einer Kategorie. Die Kategorien sind in einer mathematischen Notation beschrieben. (70, 74] bedeutet, dass dieses "bin" Werte beinhaltet zwischen 70 (exklusive) und 74 (inklusive). Mathematisch handelt es sich dabei um ein halb-offenes Intervall. D.h. dass ein Endpunkt des Intervalls inklusive ist, der anderes Endpunkt dagegen nicht. Manchmal wird es auch halb-geschlossenes Intervall genannt.

In unserem vorherigen Kapitel haben wir auch ein halb-offenes Intervall definiert, jedoch anders herum, d.h. dass die linke Seite offen und die rechte geschlossen war. Wenn wir pd.IntervalIndex.from_tuples verwenden, können wir die Öffnung der "bins" definieren, indem wir den Parameter closed auf einen der folgenden Werte setzen:

Für das gleiche Verhalten wie im vorherigen Kapitel, setzen wir den Parameter closed = "left":

bins2 = pd.IntervalIndex.from_tuples(bins, closed="left")
categorical_object = pd.cut(weights_of_persons, bins2)
print(categorical_object)
[[70, 74), [66, 70), [62, 66), [74, 78), [74, 78), ..., [58, 62), [66, 70), [74, 78), [86, 90), [62, 66)]
Length: 18
Categories (11, interval[int64]): [[50, 54) < [54, 58) < [58, 62) < [62, 66) ... [78, 82) < [82, 86) < [86, 90) < [90, 94)]

Andere Wege um Bins zu definieren

Wir haben in IntervalIndex verwendet um die Gewichtsdaten in "bins" einzuteilen. Die Funktion cut() kann noch mit zwei weiteren Arten der bin-Repräsentation umgehen:

categorical_object = pd.cut(weights_of_persons, 18)
print(categorical_object)
[(71.35, 73.456], (69.244, 71.35], (62.928, 65.033], (75.561, 77.667], (73.456, 75.561], ..., (56.611, 58.717], (67.139, 69.244], (73.456, 75.561], (88.194, 90.3], (62.928, 65.033]]
Length: 18
Categories (18, interval[float64]): [(52.362, 54.506] < (54.506, 56.611] < (56.611, 58.717] < (58.717, 60.822] ... (81.878, 83.983] < (83.983, 86.089] < (86.089, 88.194] < (88.194, 90.3]]
sequence_of_scalars = [ x[0] for x in bins]
sequence_of_scalars.append(bins[-1][1])
print(sequence_of_scalars)
categorical_object = pd.cut(weights_of_persons, 
                            sequence_of_scalars,
                            right=False)
print(categorical_object)
[50, 54, 58, 62, 66, 70, 74, 78, 82, 86, 90, 94]
[[70, 74), [66, 70), [62, 66), [74, 78), [74, 78), ..., [58, 62), [66, 70), [74, 78), [86, 90), [62, 66)]
Length: 18
Categories (11, interval[int64]): [[50, 54) < [54, 58) < [58, 62) < [62, 66) ... [78, 82) < [82, 86) < [86, 90) < [90, 94)]

Bins und Werte zählen

Die nächste und interessanteste Frage ist, wie wir denn die aktuellen Werte eines Bins sehen können. Das können wir mit der Funktion value_counts() erreichen:

pd.value_counts(categorical_object)
Der obige Code liefert folgendes Ergebnis:
[74, 78)    3
[66, 70)    3
[86, 90)    2
[82, 86)    2
[78, 82)    2
[62, 66)    2
[90, 94)    1
[70, 74)    1
[58, 62)    1
[50, 54)    1
[54, 58)    0
dtype: int64

categorical_object.codes bietet eine Beschriftung der Eingangs-Werte in die "binning"-Kategorien:

labels = categorical_object.codes
labels
Der obige Python-Code führt zu folgender Ausgabe:
array([ 5,  4,  3,  6,  6,  7,  7,  8,  9, 10,  8,  4,  0,  2,  4,  6,  9,
        3], dtype=int8)

categories ist der IntervalIndex der Kategorien der Label-Indizes:

categories = categorical_object.categories
categories
Der obige Code liefert folgendes Ergebnis:
IntervalIndex([[50, 54), [54, 58), [58, 62), [62, 66), [66, 70) ... [74, 78), [78, 82), [82, 86), [86, 90), [90, 94)]
              closed='left',
              dtype='interval[int64]')

Zusammenhang zwischen Gewichtsdaten und "bins":

for index in range(len(weights_of_persons)):
    label_index = labels[index]
    print(weights_of_persons[index], 
          label_index, 
          categories[label_index] )
73.4 5 [70, 74)
69.3 4 [66, 70)
64.9 3 [62, 66)
75.6 6 [74, 78)
74.9 6 [74, 78)
80.3 7 [78, 82)
78.6 7 [78, 82)
84.1 8 [82, 86)
88.9 9 [86, 90)
90.3 10 [90, 94)
83.4 8 [82, 86)
69.3 4 [66, 70)
52.4 0 [50, 54)
58.3 2 [58, 62)
67.4 4 [66, 70)
74.0 6 [74, 78)
89.3 9 [86, 90)
63.4 3 [62, 66)
categorical_object.categories
Der obige Code führt zu folgendem Ergebnis:
IntervalIndex([[50, 54), [54, 58), [58, 62), [62, 66), [66, 70) ... [74, 78), [78, 82), [82, 86), [86, 90), [90, 94)]
              closed='left',
              dtype='interval[int64]')

Bins benennen

Stellen wir uns vor, wir hätten eine Universität, die drei Stufen der "Latin honors" verleiht in Abhängigkeit der Durchschnittsnote (GPA - Grade point average):

degrees = ["none", "cum laude", "magna cum laude", "summa cum laude"]
student_results = [3.93, 3.24, 2.80, 2.83, 3.91, 3.698, 3.731, 3.25, 3.24, 3.82, 3.22]
student_results_degrees = pd.cut(student_results, [0, 3.6, 3.8, 3.9, 4.0], labels=degrees)
pd.value_counts(student_results_degrees)
Wir können die folgende Ausgabe erwarten, wenn wir den obigen Python-Code ausführen:
none               6
summa cum laude    2
cum laude          2
magna cum laude    1
dtype: int64

Schauen wir uns die einzelnen Bewertungen/Benotungen der Studenten an:

labels = student_results_degrees.codes
categories = student_results_degrees.categories
for index in range(len(student_results)):
    label_index = labels[index]
    print(student_results[index], label_index, categories[label_index] )
3.93 3 summa cum laude
3.24 0 none
2.8 0 none
2.83 0 none
3.91 3 summa cum laude
3.698 1 cum laude
3.731 1 cum laude
3.25 0 none
3.24 0 none
3.82 2 magna cum laude
3.22 0 none