Nächste-Nachbarn-Klassifikation

Einführung

Cluster mit Tieren

"Zeig mir deine Freunde und ich sag' dir wer du bist"

Das Konzept des Nächsten-Nachbarn-Klassifikators oder genauer es k-nächsten-Nachbarn-Klassifikators kann kaum einfacher beschrieben werden, wie mit diesem Sprichwort. Das ist eine alte Redensart, die es in vielen Sprachen und Kulturen gibt. Es wird auch in anderen Worten in der Bibel erwähnt: "Wer mit den Weisen umgeht, der wird weise; wer aber der Toren Geselle ist, der wird Unglück haben." (Sprüche 13,20)

Das k-Nearest-Neighbor Konzept ist also Teil unseres täglichen Lebens und unseres Urteilsvermögens: Stellen Sie sich vor Sie treffen eine Gruppe von Menschen. Sie sind alle jung, stylisch und sportlich. Sie reden über ihren Freund Ben, der gerade nicht dabei ist. Wie ist ihre Vorstellung zu Ben? Richtig, Sie werden wohl denken, dass Ben auch jung, stylisch und sportlich ist.

Wenn du erfährst dass Ben in einer Nachbarschaft lebt, in der konservativ gewählt wird und das Durchschnittseinkommen über 200.000 Dollar im Jahr liegt. Seine beiden direkten Nachbarn verdienen sogar mehr als 300.000 Dollar im Jahr. Was denkst du über Ben? Du wirst ihn sehr wahrscheinlich nicht für einen "Underdog" halten. Vielmehr wirst du glauben, dass er ebenfalls wohlhabend ist und höchst-wahrscheinlich nicht weniger als seine Nachbarn verdienen.

Betrachten wir das Konzept mehr aus der Richtung der Mathematik:

Wie im Kaptiel Aufteilungen der Daten dargelegt, benötigen wir gelabelte Lern- und Testdaten. Im Unterschied zu anderen Klassifikatoren findet bei den reinen NächstenNachbarn-Klassifikatoren jedoch kein Lernen statt, sondern das sogenannte Lernset $LS$ ist Grundbestandteil des Klassifikators. Der k-Nächste_Nachbarn-Klassifikator (k-NN) arbeitet direkt mit den gelernten Stichproben, anstatt Regeln zu erstelln, wie dies andere Klassifizierungsmethoden tun.
Die Aufgabe der Klassifikation besteht in der Zuordnung von Kategorien oder Klassen zu einem Element einer zu klassifizierenden Mengen von Objekten.

Nächste-Nachbarn-Algorithmus:

Es ist eine Menge $K = \{c_1,c_2, \cdots, c_m\}$ von Kategorien gegeben, die auch Klassen genannt werden. z.B. {"male", "female"}.

Außerdem sei eine weitere Menge $LS$ gegeben, die gelabellte Objekte enthält.

$$LS = \{(o_1, c_{o_1}), (o_2, c_{o_2}), \cdots (o_n, c_{o_n}) \}$$

Da es keinen Sinn ergibt, dass man weniger gelabellte Objekte als Klassen hat, muss gelten:

$n > m$ und im allgemeinen sogar $n \ggg m$ (n sehr viel größer als m.)

Die Aufgabe der Klassifikation besteht nun darin einem Objekt $o$ ein Kategorie/Klasse $c$ zuzuordnen. Dazu müssen wir zwei Fälle unterscheiden:

    1. Fall:
      Die Instanz $o$ ist in $LS$ enthalten, d.h. es gibt ein Tupel $(o, c) \in LS$
      In diesem Fall nehmen wir die Klasse $c$ als Klassifikationsergebnis.
    1. Fall:
      Nun nehmen wir an, dass $o$ nicht in $LS$ enthalten ist, genauer gesagt:
      $\forall c \in C, (o, c) \not\in LS$
      $o$ wird nun mit allen Instanzen von $LS$ verglichen. Eine Abstandsmetrik $d$ wird für die Vergleiche benutzt.
      Wir bestimmen die $k$ nächsten Nachbarn von $o$, d.h. die Instanzen mit den kleinsten Entfernungen.
      $k$ ist eine benutzerdefinierte positive ganze Zahl, die überlicherweise nicht sehr groß ist.
      Um die $k$-nächsten Nachbarn zu bestimmen, ordnen wir $LS$ auf die foldende Art um:
      $(o_{i_1}, c_{o_{i_1}}), (o_{i_2}, c_{o_{i_2}}), \cdots (o_{i_n}, c_{o_{i_n}})$
      wobei für alle $1 \leq j \leq {n-1}$ gilt $d(o_{i_j}, o) \leq d(o_{i_{j+1}}, o)$ Die Menge der nächsten Nachbarn $N_k$ besteht aus den ersten $k$ Elementen dieser Ordnung, d.h.
      $N_k = \{ (o_{i_1}, c_{o_{i_1}}), (o_{i_2}, c_{o_{i_2}}), \cdots (o_{i_k}, c_{o_{i_k}}) \}$
      Die am häufigsten vorkommende Klasse in dieser Menge $N_k$ wird zum Ergebnis der Klassifikation $o$. Wenn es keine eindeutige Wenn sich keine KLasse eindeutig als häufigste bestimmen lässt, nehmen wir willkürlich eine der Kandidaten.

Es gibt keine allgemeine Möglichkeit, einen optimalen Wert für k zu definieren. Dieser Wert hängt von den Daten ab. In der Regel können wir sagen, dass bei zunehmendem k das Rauschen reduziert wird, andererseits werden die Grenzen jedoch weniger scharf.

Der Algorithmus für den k-Nearest-Neighbor Klassifikator ist einer der einfachsten von allen Machine Learning Algorithmen. k-NN gehört zu den Typen des instanzbasierten Lernens, oder lazy-Lernens. In der Theorie des Maschinellen Lernens wird Lazy-lernen als Lernmethode verstanden, in der die Verallgemeinerung der Trainingsdaten verzögert wird, bis eine Abfrage an das System vorgenommen wird. Andererseits haben wir das Eager-Lernen, wo das System die Trainingsdaten in der Regel verallgemeint, bevor es Abfragen erhält. In anderen Worten: Die Funktion wird nur lokal approximiert und alle Berechnungen werden bei der jeweils aktuellen Klassifikation durchgeführt.

Das folgende Bild zeigt auf einfache Weise, wie der nächste Nachbar-Klassifikator funktioniert. Das Puzzleteil ist unbekannt. Um herauszufinden, welches Tier es sein könnte, müssen wir die Nachbarn finden. Wenn k = 1, ist der einzige Nachbarn eine Katze und wir nehmen in diesem Fall an, dass das Puzzleteil auch eine Katze sein sollte. Wenn k = 4 gilt, enthalten die nächsten Nachbarn ein Huhn und drei Katzen. In diesem Fall können wir annehmen, dass unser Objekt, also das Puzzleteil, eine Katze sein sollte.

Nearest Neighbor, way of working

k-Nächster-Nachbar von Grund auf

Dataset vorbereiten

Bevor wir einen Nearest-Neighbor Klassifikator schreiben, müssen wir über die Daten nachdenken, das heißt den Lern- und den Testdatensatz. Wir benutzen die "Iris"-Daten, die durch das sklearn-Modul zur Verfügung gestellt werden.

Der Datensatz beinhaltet 50 Beispiele von drei Arten von Schwertlilien (Englisch: Iris).

  • Iris setosa (Borsten-Schwertlilie)
  • Iris virginica und
  • Iris versicolor (Verschiedenfarbige-Schwertlilie)

Vier Features (Merkmale) werden bei jeder Probe gemessen:

  • Die Länge der Kelchblätter in Zentimeter
  • Die Breite der Kelchblätter in Zentimeter
  • Die Länge der Blütenblätter in Zentimeter
  • Die Breite der Blütenblätter in Zentimeter
In [19]:
import numpy as np
from sklearn import datasets

iris = datasets.load_iris()
data = iris.data
labels = iris.target

for i in [0, 79, 99, 101]:
    print(f"Index: {i:3}, Merkmale: {data[i]}, Label: {labels[i]}")
Index:   0, Merkmale: [5.1 3.5 1.4 0.2], Label: 0
Index:  79, Merkmale: [5.7 2.6 3.5 1. ], Label: 1
Index:  99, Merkmale: [5.7 2.8 4.1 1.3], Label: 1
Index: 101, Merkmale: [5.8 2.7 5.1 1.9], Label: 2

Erstellen wir ein Learnset aus den oben genannten Daten. Wir verwenden die Funktion permutation aus np.random um die Daten zufällig aufzuteilen.

In [20]:
# Seeding ist nur für die Website erforderlich,
# damit die Werte immer gleich sind: 
np.random.seed(42)
indices = np.random.permutation(len(data))

n_training_samples = 12
learn_data = data[indices[:-n_training_samples]]
learn_labels = labels[indices[:-n_training_samples]]
test_data = data[indices[-n_training_samples:]]
test_labels = labels[indices[-n_training_samples:]]

print("Die ersten Stichproben unseres Lernsets:")
print(f"{'Index':7s}{'Daten':20s}{'Label':3s}")
for i in range(5):
    print(f"{i:4d}   {learn_data[i]}   {learn_labels[i]:3}")

print("Die ersten Stichproben unseres Testsets:")
print(f"{'Index':7s}{'Daten':20s}{'Label':3s}")
for i in range(5):
    print(f"{i:4d}   {learn_data[i]}   {learn_labels[i]:3}")
Die ersten Stichproben unseres Lernsets:
Index  Daten               Label
   0   [6.1 2.8 4.7 1.2]     1
   1   [5.7 3.8 1.7 0.3]     0
   2   [7.7 2.6 6.9 2.3]     2
   3   [6.  2.9 4.5 1.5]     1
   4   [6.8 2.8 4.8 1.4]     1
Die ersten Stichproben unseres Testsets:
Index  Daten               Label
   0   [6.1 2.8 4.7 1.2]     1
   1   [5.7 3.8 1.7 0.3]     0
   2   [7.7 2.6 6.9 2.3]     2
   3   [6.  2.9 4.5 1.5]     1
   4   [6.8 2.8 4.8 1.4]     1

Der folgende Code dienst nur der Visualisierung der Daten aus dem Lernset. Unsere Daten bestehen aus vier Werten pro Schwertlilie. Um die Daten etwas zu reduzieren, summieren wir die dritten und vierten Werte auf. So können wir die Werte in einem 3-Dimensionalen Raum darstellen:

In [21]:
import matplotlib.pyplot as plt
from mpl_toolkits.mplot3d import Axes3D

X = []
for iclass in range(3):
    X.append([[], [], []])
    for i in range(len(learn_data)):
        if learn_labels[i] == iclass:
            X[iclass][0].append(learn_data[i][0])
            X[iclass][1].append(learn_data[i][1])
            X[iclass][2].append(sum(learn_data[i][2:]))

colours = ("r", "g", "y")

fig = plt.figure()
ax = fig.add_subplot(111, projection='3d')

for iclass in range(3):
    ax.scatter(X[iclass][0], X[iclass][1], X[iclass][2], c=colours[iclass])
plt.show()
No description has been provided for this image

Entfernungsmetriken

Wir wir bereits ausführlich erwähnt haben, berechnen wir die Abstände zwischen den Punkten der Stichprobe und dem zu klassifizierenden Objekt. Zur Berechnung dieser Abstände benötigen wir eine Abstandsfunktion.

In n-dimensionalen Vektorräumen, benutzt man meistens einer der folgenden drei Abstandsmetriken:

  • Euklidischer-Abstand

    Der euklidische Abstand zwischen zwei Punkten x und y entweder in der Ebene oder im 3-dimensionalen Raum entspricht der Länge der Strecke, die diese beiden Punkte verbindet. Es kann aus den kartesischen Koordinaten der Punkte mit dem Satz des Pythagoras berechnet werden, daher wird es gelegentlich auch die Pythagoros-Entfernung bezeichnet. Die allgemeine Formel lautet:

    $$d(x, y) = \sqrt{\sum_{i=1}^{n} (x_i -y_i)^2}$$

  • Manhatten-Abstand

    Es ist definiert als die Summe der absoluten Werte zwischen den Koordinaten von x und y : $$d(x, y) = \sum_{i=1}^{n} |x_i -y_i|$$

  • Minkowski-Abstand

    Der Minkowski-Abstand verallgemeinert den Euklidischen und Manhatten-Abstand in einer Distanzmetrik. Wenn wir den Parameter p in der folgenden Formel auf 1 einstellen, erhalten wir den Manhatten-Abstand, und mit dem Wert 2 erhalten wir den Euklidischen-Abstand: $$d(x, y) = { \left(\sum_{i=1}^{n} (x_i -y_i)^p \right)}^{\frac{1}{p}}$$

Das folgende Diagramm veranschaulicht den Zusammenhang zwischen Manhatten- und Euklidschem Abstand:

Manhattan and Euclidean distance

Die blaue Linie veranschaulicht den Euklidschen Abstand zwischen dem grünen und dem roten Punkt. Ansonsten kann man sich auch über die orange, grüne oder gelbe Linie vom grünen Punkt zum roten Punkt bewegen. Die Linien entsprechen der Manhatten-Distanz. Die Länge ist jeweils gleich.

Die Nachbarn ermitteln

Um Ähnlichkeiten zwischen zwei Objekten zu ermitteln brauchen wir eine Distanz-Funktion. In unserem Beispiel benutzen wir den Euklidischen Abstand.

Der euklidische Abstand zweier Punkte in der Ebene oder im Raum ist die zum Beispiel mit einem Lineal gemessene Länge einer Strecke, die diese zwei Punkte verbindet. In kartesischen Koordinaten kann der euklidische Abstand mit Hilfe des Satzes von Pythagoras berechnet werden. Mit Hilfe der so gewonnenen Formel kann der Begriff des euklidischen Abstands auf n-dimensionale Koordinatenräume verallgemeinert werden. Berechnen können wir den Abstand mit der Funktion norm aus dem Modul np.linalg:

In [22]:
def distance(instance1, instance2):
    """ Berechnet den Eukliischen Abstand zwischen 
    zwei Instanzen"""
    return np.linalg.norm(np.subtract(instance1, instance2))

print(distance([3, 5], [1, 1]))
print(distance(learn_data[3], learn_data[44]))
4.47213595499958
3.4190641994557516

Die Funktion get_neighbors liefert eine Liste mit k Nachbarn zurück, die der Instanz test_instance am nächsten sind:

In [23]:
def get_neighbors(training_set, 
                  labels, 
                  test_instance, 
                  k, 
                  distance):
    """
    get_neighbors berechnet eine Liste mit k-Nachbarn, 
    die der Instanz 'test_instance' am nächsten liegen.
    
    'get_neighbors' liefert eine Liste mit k 3er-Tupel zurück.
    Jedes Tupel besteht aus (index, dist, label), wobei gilt
    
    index    ist der Index aus training_set,
    dist     ist die Distanz zwischen der test_instance und der Instanz training_set[index]
    distance ist eine Referenz auf die Funktion, welche die Distanz berechnet
    """
    distances = []
    for index in range(len(training_set)):
        dist = distance(test_instance, training_set[index])
        distances.append((training_set[index], dist, labels[index]))
    distances.sort(key=lambda x: x[1])
    neighbors = distances[:k]
    return neighbors

Wir testen die Funktion mit unseren Iris-Proben:

In [24]:
for i in range(5):
    neighbors = get_neighbors(learn_data, 
                              learn_labels, 
                              test_data[i], 
                              3, 
                              distance=distance)

    print("Index:         ",i,'\n',
          "Testset-Data:  ",test_data[i],'\n', 
          "Testset-Label: ",test_labels[i],'\n', 
          "Nachbarn:      ",neighbors,'\n')
Index:          0 
 Testset-Data:   [5.7 2.8 4.1 1.3] 
 Testset-Label:  1 
 Nachbarn:       [(array([5.7, 2.9, 4.2, 1.3]), 0.14142135623730995, 1), (array([5.6, 2.7, 4.2, 1.3]), 0.17320508075688815, 1), (array([5.6, 3. , 4.1, 1.3]), 0.22360679774997935, 1)] 

Index:          1 
 Testset-Data:   [6.5 3.  5.5 1.8] 
 Testset-Label:  2 
 Nachbarn:       [(array([6.4, 3.1, 5.5, 1.8]), 0.1414213562373093, 2), (array([6.3, 2.9, 5.6, 1.8]), 0.24494897427831785, 2), (array([6.5, 3. , 5.2, 2. ]), 0.3605551275463988, 2)] 

Index:          2 
 Testset-Data:   [6.3 2.3 4.4 1.3] 
 Testset-Label:  1 
 Nachbarn:       [(array([6.2, 2.2, 4.5, 1.5]), 0.2645751311064586, 1), (array([6.3, 2.5, 4.9, 1.5]), 0.574456264653803, 1), (array([6. , 2.2, 4. , 1. ]), 0.5916079783099617, 1)] 

Index:          3 
 Testset-Data:   [6.4 2.9 4.3 1.3] 
 Testset-Label:  1 
 Nachbarn:       [(array([6.2, 2.9, 4.3, 1.3]), 0.20000000000000018, 1), (array([6.6, 3. , 4.4, 1.4]), 0.2645751311064587, 1), (array([6.6, 2.9, 4.6, 1.3]), 0.3605551275463984, 1)] 

Index:          4 
 Testset-Data:   [5.6 2.8 4.9 2. ] 
 Testset-Label:  2 
 Nachbarn:       [(array([5.8, 2.7, 5.1, 1.9]), 0.31622776601683755, 2), (array([5.8, 2.7, 5.1, 1.9]), 0.31622776601683755, 2), (array([5.7, 2.5, 5. , 2. ]), 0.33166247903553986, 2)] 

Voting für ein einzelnes Ergebnis

Wir schreiben eine Voting-Funktion (Abstimmung). Diese Funktion benutzt die Klasse Counter aus collections um die Anzahl der Klassen in einer Instanzen-Liste zu ermitteln. Die Instanzen-Liste stellt natürlich die Nachbarn dar. Die Funktion vote liefert die passendste Klasse zurück.

In [25]:
from collections import Counter

def vote(neighbors):
    class_counter = Counter()
    for neighbor in neighbors:
        class_counter[neighbor[2]] += 1
    return class_counter.most_common(1)[0][0]

Testen wir die vote Funktion mit unseren Trainings-Proben:

In [26]:
for i in range(n_training_samples):
    neighbors = get_neighbors(learn_data, 
                              learn_labels, 
                              test_data[i], 
                              3, 
                              distance=distance)
    print("Index: ", i, 
          ", Ergebnis(vote): ", vote(neighbors), 
          ", Bezeichnung: ", test_labels[i], 
          ", Daten: ", test_data[i])
Index:  0 , Ergebnis(vote):  1 , Bezeichnung:  1 , Daten:  [5.7 2.8 4.1 1.3]
Index:  1 , Ergebnis(vote):  2 , Bezeichnung:  2 , Daten:  [6.5 3.  5.5 1.8]
Index:  2 , Ergebnis(vote):  1 , Bezeichnung:  1 , Daten:  [6.3 2.3 4.4 1.3]
Index:  3 , Ergebnis(vote):  1 , Bezeichnung:  1 , Daten:  [6.4 2.9 4.3 1.3]
Index:  4 , Ergebnis(vote):  2 , Bezeichnung:  2 , Daten:  [5.6 2.8 4.9 2. ]
Index:  5 , Ergebnis(vote):  2 , Bezeichnung:  2 , Daten:  [5.9 3.  5.1 1.8]
Index:  6 , Ergebnis(vote):  0 , Bezeichnung:  0 , Daten:  [5.4 3.4 1.7 0.2]
Index:  7 , Ergebnis(vote):  1 , Bezeichnung:  1 , Daten:  [6.1 2.8 4.  1.3]
Index:  8 , Ergebnis(vote):  1 , Bezeichnung:  2 , Daten:  [4.9 2.5 4.5 1.7]
Index:  9 , Ergebnis(vote):  0 , Bezeichnung:  0 , Daten:  [5.8 4.  1.2 0.2]
Index:  10 , Ergebnis(vote):  1 , Bezeichnung:  1 , Daten:  [5.8 2.6 4.  1.2]
Index:  11 , Ergebnis(vote):  2 , Bezeichnung:  2 , Daten:  [7.1 3.  5.9 2.1]

Wir sehen, dass die Vorhersagen mit den Bezeichnungen übereinstimmen, ausgenommen der Fall mit dem Index 8.

vote_prob ist eine Funktion wie vote, liefert aber den Klassen-Namen und dessen Wahrscheinlichkeit:

In [27]:
def vote_prob(neighbors):
    class_counter = Counter()
    for neighbor in neighbors:
        class_counter[neighbor[2]] += 1
    labels, votes = zip(*class_counter.most_common())
    winner = class_counter.most_common(1)[0][0]
    votes4winner = class_counter.most_common(1)[0][1]
    return winner, votes4winner/sum(votes)
In [30]:
for i in range(n_training_samples):
    neighbors = get_neighbors(learn_data, 
                              learn_labels, 
                              test_data[i], 
                              5, 
                              distance=distance)
    print("Index: ", i, 
          ", Ergebnis(vote_prob): ", vote_prob(neighbors), 
          ", Bezeichnung: ", test_labels[i], 
          ", Daten: ", test_data[i])
Index:  0 , Ergebnis(vote_prob):  (1, 1.0) , Bezeichnung:  1 , Daten:  [5.7 2.8 4.1 1.3]
Index:  1 , Ergebnis(vote_prob):  (2, 1.0) , Bezeichnung:  2 , Daten:  [6.5 3.  5.5 1.8]
Index:  2 , Ergebnis(vote_prob):  (1, 1.0) , Bezeichnung:  1 , Daten:  [6.3 2.3 4.4 1.3]
Index:  3 , Ergebnis(vote_prob):  (1, 1.0) , Bezeichnung:  1 , Daten:  [6.4 2.9 4.3 1.3]
Index:  4 , Ergebnis(vote_prob):  (2, 1.0) , Bezeichnung:  2 , Daten:  [5.6 2.8 4.9 2. ]
Index:  5 , Ergebnis(vote_prob):  (2, 0.8) , Bezeichnung:  2 , Daten:  [5.9 3.  5.1 1.8]
Index:  6 , Ergebnis(vote_prob):  (0, 1.0) , Bezeichnung:  0 , Daten:  [5.4 3.4 1.7 0.2]
Index:  7 , Ergebnis(vote_prob):  (1, 1.0) , Bezeichnung:  1 , Daten:  [6.1 2.8 4.  1.3]
Index:  8 , Ergebnis(vote_prob):  (1, 1.0) , Bezeichnung:  2 , Daten:  [4.9 2.5 4.5 1.7]
Index:  9 , Ergebnis(vote_prob):  (0, 1.0) , Bezeichnung:  0 , Daten:  [5.8 4.  1.2 0.2]
Index:  10 , Ergebnis(vote_prob):  (1, 1.0) , Bezeichnung:  1 , Daten:  [5.8 2.6 4.  1.2]
Index:  11 , Ergebnis(vote_prob):  (2, 1.0) , Bezeichnung:  2 , Daten:  [7.1 3.  5.9 2.1]

Der gewichtete Nearest-Neighbor-Klassifikator

Wir haben bis jetzt nur k Elemente aus der Nachbarschaft eines unbekannten Objektes "UO" betrachtet, und dessen Mehrheit. Die Mehrheit der Stimmabgaben hat sich im vorigen Beispiel stark gezeigt, wurde aber bei der folgenden Argumentation nicht berücksichtigt: "Je weiter ein Nachbar, desto mehr weicht er vom 'echten' Ergebnis ab." Um es mit anderen Worten zu sagen: "Wir können dem nächsten Nachbarn mehr vertrauen als dem, der am weitesten entfernt ist." Nehmen wir an, dass das unbekannte Objekt "UO" 11 Nachbarn hat. Die naheliegendsten fünf Nachbarn gehören zur Klasse A und alle anderen sechs, die weiter entfernt sind, gehören zur Klasse B. Zu welcher Klasse wir "UO" wohl gehören? Das vorherige Vorgehen würde "Klasse B" sagen, denn wir haben ein Verhältnis von 6 zu 5 für Klasse B. Auf der anderen Seite gehören die naheliegendsten zur Klasse A und das sollte stärker berücksichtigt werden. Um die Strategie weiterzuverfolgen, können wir den Nachbarn Gewichtungen zuweisen: Der naheliegendste Nachbar einer Instanz erhält ein Gewicht von $1 / 1$, der zweitnächste bekommt ein Gewicht von $1 / 2$. Mit den weiteren Nachbarn wird ebenso weiterverfahren bis zu einem Gewicht von $1/k$ für den am weitesten entfernten Nachbarn.

Wir verwenden also die harmonische Serie als Gewichtung:

$$\sum_{i}^{k}{1/(i+1)} = 1 + \frac{1}{2} + \frac{1}{3} + ... + \frac{1}{k}$$

In der folgenden Funktion haben wir dies implementiert:

In [31]:
def vote_harmonic_weights(neighbors, all_results=True):
    class_counter = Counter()
    number_of_neighbors = len(neighbors)
    for index in range(number_of_neighbors):
        class_counter[neighbors[index][2]] += 1/(index+1)
    labels, votes = zip(*class_counter.most_common())
    #print(labels, votes)
    winner = class_counter.most_common(1)[0][0]
    votes4winner = class_counter.most_common(1)[0][1]
    if all_results:
        total = sum(class_counter.values(), 0.0)
        for key in class_counter:
             class_counter[key] /= total
        return winner, class_counter.most_common()
    else:
        return winner, votes4winner / sum(votes)        
In [32]:
for i in range(n_training_samples):
    neighbors = get_neighbors(learn_data, 
                              learn_labels, 
                              test_data[i], 
                              6, 
                              distance=distance)
    print("Index: ", i, 
          ", Ergebnis(vote_harmonic_weights): ", 
          vote_harmonic_weights(neighbors,
                                all_results=True))
Index:  0 , Ergebnis(vote_harmonic_weights):  (1, [(1, 1.0)])
Index:  1 , Ergebnis(vote_harmonic_weights):  (2, [(2, 1.0)])
Index:  2 , Ergebnis(vote_harmonic_weights):  (1, [(1, 1.0)])
Index:  3 , Ergebnis(vote_harmonic_weights):  (1, [(1, 1.0)])
Index:  4 , Ergebnis(vote_harmonic_weights):  (2, [(2, 0.9319727891156463), (1, 0.06802721088435375)])
Index:  5 , Ergebnis(vote_harmonic_weights):  (2, [(2, 0.8503401360544217), (1, 0.14965986394557826)])
Index:  6 , Ergebnis(vote_harmonic_weights):  (0, [(0, 1.0)])
Index:  7 , Ergebnis(vote_harmonic_weights):  (1, [(1, 1.0)])
Index:  8 , Ergebnis(vote_harmonic_weights):  (1, [(1, 1.0)])
Index:  9 , Ergebnis(vote_harmonic_weights):  (0, [(0, 1.0)])
Index:  10 , Ergebnis(vote_harmonic_weights):  (1, [(1, 1.0)])
Index:  11 , Ergebnis(vote_harmonic_weights):  (2, [(2, 1.0)])

Das vorherige Vorgehen berücksichtigt lediglich die Reihenfolge der Nachbarn entsprechend der Entfernung. Wir können die Auswahl aber noch verbessern indem die aktuelle tatsächliche Entfernung berücksichtigt wird. Dafür schreiben wir eine neue Funktion:

In [33]:
def vote_distance_weights(neighbors, all_results=True):
    class_counter = Counter()
    number_of_neighbors = len(neighbors)
    for index in range(number_of_neighbors):
        dist = neighbors[index][1]
        label = neighbors[index][2]
        class_counter[label] += 1 / (dist**2 + 1)
    labels, votes = zip(*class_counter.most_common())
    #print(labels, votes)
    winner = class_counter.most_common(1)[0][0]
    votes4winner = class_counter.most_common(1)[0][1]
    if all_results:
        total = sum(class_counter.values(), 0.0)
        for key in class_counter:
             class_counter[key] /= total
        return winner, class_counter.most_common()
    else:
        return winner, votes4winner / sum(votes)
In [34]:
for i in range(n_training_samples):
    neighbors = get_neighbors(learn_data, 
                              learn_labels, 
                              test_data[i], 
                              6, 
                              distance=distance)
    print("Index: ", i, 
          ", Ergebnis(vote_distance_weights): ", vote_distance_weights(neighbors,
                                                      all_results=True))
Index:  0 , Ergebnis(vote_distance_weights):  (1, [(1, 1.0)])
Index:  1 , Ergebnis(vote_distance_weights):  (2, [(2, 1.0)])
Index:  2 , Ergebnis(vote_distance_weights):  (1, [(1, 1.0)])
Index:  3 , Ergebnis(vote_distance_weights):  (1, [(1, 1.0)])
Index:  4 , Ergebnis(vote_distance_weights):  (2, [(2, 0.8490154592118361), (1, 0.15098454078816387)])
Index:  5 , Ergebnis(vote_distance_weights):  (2, [(2, 0.6736137462184479), (1, 0.3263862537815521)])
Index:  6 , Ergebnis(vote_distance_weights):  (0, [(0, 1.0)])
Index:  7 , Ergebnis(vote_distance_weights):  (1, [(1, 1.0)])
Index:  8 , Ergebnis(vote_distance_weights):  (1, [(1, 1.0)])
Index:  9 , Ergebnis(vote_distance_weights):  (0, [(0, 1.0)])
Index:  10 , Ergebnis(vote_distance_weights):  (1, [(1, 1.0)])
Index:  11 , Ergebnis(vote_distance_weights):  (2, [(2, 1.0)])

Übung und Beispiel: Klassifizierung von Früchten mit K-Nächste-Nachbarn

Korb mit Äpfeln, Mangos und Zitronen

Datensatz: Erzeugen Sie einen Datensatz mit Informationen über Früchte mit drei Merkmalen: Süße, Säuregehalt und Gewicht (in Gramm). Wir betrachten drei Arten von Früchten: "Apfel", "Zitrone", und "Mango".

Zielsetzung: Erstellen eines K-nearest neighbors Klassifikators zur Vorhersage der Obstsorte auf der Grundlage von Süße, Säuregehalt und Gewicht.

Zunächst werden wir Zufallsdaten mit Früchten und entsprechenden Daten erstellen:

Wir verwenden die [Brix-Skala] (https://en.wikipedia.org/wiki/Brix) für die Süße von Äpfeln.

In [35]:
import numpy as np

np.random.seed(42)

def create_features(number_samples, *min_max_features):
    """ Creates an array with number_samples rows and len(min_max_features) columns """
    features = []
    for min_val, max_val,rounding in min_max_features:
        features.append(np.random.uniform(min_val, max_val, number_samples).round(rounding))
    result = np.column_stack(features)
    return result

num_apples, num_mangos, num_lemons = 40, 42, 45
sweetness = (10, 18, 0)
acidity = 3.4, 4, 2
weight = 140.0, 250.0, 0
apples = create_features(num_apples, sweetness, acidity, weight)
apples
Out[35]:
array([[ 13.  ,   3.47, 235.  ],
       [ 18.  ,   3.7 , 209.  ],
       [ 16.  ,   3.42, 176.  ],
       [ 15.  ,   3.95, 147.  ],
       [ 11.  ,   3.56, 174.  ],
       [ 11.  ,   3.8 , 176.  ],
       [ 10.  ,   3.59, 220.  ],
       [ 17.  ,   3.71, 210.  ],
       [ 15.  ,   3.73, 238.  ],
       [ 16.  ,   3.51, 192.  ],
       [ 10.  ,   3.98, 153.  ],
       [ 18.  ,   3.87, 218.  ],
       [ 17.  ,   3.96, 224.  ],
       [ 12.  ,   3.94, 202.  ],
       [ 11.  ,   3.76, 225.  ],
       [ 11.  ,   3.95, 194.  ],
       [ 12.  ,   3.45, 198.  ],
       [ 14.  ,   3.52, 187.  ],
       [ 13.  ,   3.43, 143.  ],
       [ 12.  ,   3.6 , 152.  ],
       [ 15.  ,   3.63, 143.  ],
       [ 11.  ,   3.56, 210.  ],
       [ 12.  ,   3.9 , 175.  ],
       [ 13.  ,   3.61, 196.  ],
       [ 14.  ,   3.57, 240.  ],
       [ 16.  ,   3.73, 167.  ],
       [ 12.  ,   3.48, 185.  ],
       [ 14.  ,   3.88, 223.  ],
       [ 15.  ,   3.44, 165.  ],
       [ 10.  ,   3.99, 148.  ],
       [ 15.  ,   3.86, 172.  ],
       [ 11.  ,   3.52, 158.  ],
       [ 11.  ,   3.4 , 242.  ],
       [ 18.  ,   3.89, 229.  ],
       [ 18.  ,   3.82, 210.  ],
       [ 16.  ,   3.84, 236.  ],
       [ 12.  ,   3.86, 228.  ],
       [ 11.  ,   3.44, 161.  ],
       [ 15.  ,   3.62, 238.  ],
       [ 14.  ,   3.47, 199.  ]])
In [36]:
sweetness = (6, 14, 0)
acidity = 5.8, 6, 1
weight = 100.0, 300.0, 0
mangos = create_features(num_mangos, sweetness, acidity, weight)

sweetness = (6, 12, 0)
acidity = 2.0, 2.6, 1
weight = 130, 170, 0
lemons = create_features(num_lemons, sweetness, acidity, weight)
In [37]:
from sklearn.model_selection import train_test_split
from sklearn.preprocessing import StandardScaler

# Combine the data and create labels
X = np.vstack((apples, mangos, lemons))
y = np.array(['Apple'] * num_apples + ['Mango'] * num_mangos + ['lemon'] * num_lemons)

# Split the data into training and testing sets
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=42)
In [38]:
n_test_samples = len(X_test)
    
for i in range(n_test_samples):
    neighbors = get_neighbors(X_train, 
                              y_train, 
                              X_test[i], 
                              6, 
                              distance=distance)

    print("index: ", i, 
          ", result of vote: ", 
          vote_harmonic_weights(neighbors,
                                all_results=True))
index:  0 , result of vote:  ('Apple', [('Apple', 0.7959183673469387), ('Mango', 0.2040816326530612)])
index:  1 , result of vote:  ('lemon', [('lemon', 0.8979591836734694), ('Apple', 0.10204081632653063)])
index:  2 , result of vote:  ('Mango', [('Mango', 1.0)])
index:  3 , result of vote:  ('Mango', [('Mango', 1.0)])
index:  4 , result of vote:  ('Mango', [('Mango', 0.8503401360544217), ('lemon', 0.14965986394557826)])
index:  5 , result of vote:  ('lemon', [('lemon', 0.9319727891156463), ('Apple', 0.06802721088435375)])
index:  6 , result of vote:  ('Mango', [('Mango', 0.8503401360544217), ('lemon', 0.14965986394557826)])
index:  7 , result of vote:  ('lemon', [('lemon', 0.8503401360544217), ('Mango', 0.14965986394557826)])
index:  8 , result of vote:  ('lemon', [('lemon', 0.9319727891156463), ('Mango', 0.06802721088435375)])
index:  9 , result of vote:  ('Mango', [('Mango', 1.0)])
index:  10 , result of vote:  ('lemon', [('lemon', 0.5918367346938775), ('Mango', 0.4081632653061224)])
index:  11 , result of vote:  ('Apple', [('Apple', 0.7619047619047619), ('Mango', 0.23809523809523805)])
index:  12 , result of vote:  ('Apple', [('Apple', 0.5102040816326531), ('lemon', 0.272108843537415), ('Mango', 0.217687074829932)])
index:  13 , result of vote:  ('lemon', [('lemon', 0.9319727891156463), ('Mango', 0.06802721088435375)])
index:  14 , result of vote:  ('Mango', [('Mango', 0.6462585034013606), ('Apple', 0.35374149659863946)])
index:  15 , result of vote:  ('lemon', [('lemon', 0.8163265306122448), ('Apple', 0.1020408163265306), ('Mango', 0.08163265306122448)])
index:  16 , result of vote:  ('lemon', [('lemon', 0.8979591836734694), ('Mango', 0.10204081632653063)])
index:  17 , result of vote:  ('lemon', [('lemon', 1.0)])
index:  18 , result of vote:  ('lemon', [('lemon', 1.0)])
index:  19 , result of vote:  ('Apple', [('Apple', 0.8503401360544217), ('Mango', 0.14965986394557826)])
index:  20 , result of vote:  ('Apple', [('Apple', 0.8639455782312925), ('Mango', 0.13605442176870747)])
index:  21 , result of vote:  ('lemon', [('lemon', 0.9183673469387754), ('Mango', 0.0816326530612245)])
index:  22 , result of vote:  ('lemon', [('lemon', 0.9319727891156463), ('Mango', 0.06802721088435375)])
index:  23 , result of vote:  ('Mango', [('Mango', 0.9319727891156463), ('Apple', 0.06802721088435375)])
index:  24 , result of vote:  ('Apple', [('Apple', 0.8163265306122448), ('Mango', 0.18367346938775508)])
index:  25 , result of vote:  ('Mango', [('Mango', 1.0)])
In [ ]:
Um unsere Klassifizierung zu bewerten, schreiben wir nun eine Bewertungsfunktion:
In [39]:
def evaluate(X_train, X_test, y_train, y_test, threshold=0):
    """
    Bewertet die Leistung eines K-Nächste-Nachbarn-Klassifikators.

    Parameter:
        X_train (ndarray): Trainingsmerkmale.
        X_test (ndarray): Test-Merkmale.
        y_train (ndarray): Trainingsbezeichnungen.
        y_test (ndarray): Testkennungen.
        threshold (Float): Schwellenwert für die Entscheidungssicherheit (Standard: 0).
                           Wenn die Wahrscheinlichkeit der vorhergesagten Klasse unter diesem Schwellenwert liegt,
                           wird die Probe als 'undecided' (unentschieden) markiert.

    Rückgabe:
        dict: Ein Wörterbuch, das die Anzahl der richtigen Vorhersagen, falschen Vorhersagen
              und unentschiedenen Vorhersagen beinhaltet

    Anmerkung:
        Die Funktion geht davon aus, dass es eine andere Funktion namens get_neighbors gibt, die
        die k-nächsten Nachbarn für jede Stichprobe abruft, und die vom K-nearest neighbors-Algorithmus verwendete
        vom K-Nächste-Nachbarn-Algorithmus verwendete Abstandsmaß ist in einer Variablen namens 'distance' definiert.
    """
    evaluation = dict(corrects=0, wrongs=0, undecided=0)
    n_test_samples = len(X_test)
    for i in range(n_test_samples):
        neighbors = get_neighbors(X_train, 
                                  y_train, 
                                  X_test[i], 
                                  6, 
                                  distance=distance)
        class_label, probability = vote_distance_weights(neighbors, all_results=False)
        if class_label == y_test[i]:
            if probability >= threshold:
                evaluation['corrects'] += 1
            else:
                evaluation['undecided'] += 1
        else:
            if probability >= threshold:
                evaluation['wrongs'] += 1
            else:
                evaluation['undecided'] += 1
    return evaluation


evaluate(X_train, X_test, y_train, y_test, 0.5)
Out[39]:
{'corrects': 21, 'wrongs': 5, 'undecided': 0}
In [40]:
evaluate(X_train, X_test, y_train, y_test, 0.9)
Out[40]:
{'corrects': 13, 'wrongs': 1, 'undecided': 12}

kNN in der Linguistik

Das nächste Beispiel kommt aus der Computer-Linguistik. Wir schauen uns an, wie wir mit einem k-Nearest-Neighbor-Klassifikator falsch geschriebene Worte erkennen können.

Dazu verwenden wir das Modul levenshtein, dass wir in unserem Tutorial zu Levenshtein Distanz bereits implementiert haben.

In [43]:
from levenshtein import levenshtein

cities = open("data/city_names.txt").readlines()
cities = [city.strip() for city in cities]

for city in ["Freiburg", "Frieburg", "Freiborg", 
             "Hamborg", "Sahrluis"]:
    neighbors = get_neighbors(cities, 
                              cities, 
                              city, 
                              2,
                              distance=levenshtein)

    print("Ergebnis(vote_distance_weights): ", vote_distance_weights(neighbors))
Ergebnis(vote_distance_weights):  ('Freiberg', [('Freiberg', 0.8333333333333334), ('Freising', 0.16666666666666669)])
Ergebnis(vote_distance_weights):  ('Lüneburg', [('Lüneburg', 0.5), ('Duisburg', 0.5)])
Ergebnis(vote_distance_weights):  ('Freiberg', [('Freiberg', 0.8333333333333334), ('Freising', 0.16666666666666669)])
Ergebnis(vote_distance_weights):  ('Hamburg', [('Hamburg', 0.7142857142857143), ('Bamberg', 0.28571428571428575)])
Ergebnis(vote_distance_weights):  ('Saarlouis', [('Saarlouis', 0.8387096774193549), ('Bayreuth', 0.16129032258064516)])

Marvin und James führen uns in die Problematik des nächsten Beispiels ein:

Marvin hat ein Problem

Kannst du Marvin und James helfen?

k-nächster-Nachbar, Lösung des Problems

Sie benötigen ein Englisch-Wörterbuch und einen k-nächsten-Nachbarn-Klassifikator, um das folgende Problem zu lösen. Sollten Sie mit Linux (speziell Ubuntu) arbeiten, finden Sie eine Datei mit einem British-English-Wörterbuch unter /usr/share/dict/british-english. Ansonsten können Sie die Datei hier herunterladen (british-english.txt).

Wir verwenden im nächsten Beispiel Worte, die ziemlich falsch geschrieben sind. Wir sehen dass unsere einfache vote_prob Funktion gute Arbeit leistet, ausser in zwei Fällen: Bei der Korrektur von "holpposs" zu "helpless" und "blagrufoo" zu "barefoot". Während unsere vote_distance_weights Funktion in allen Fällen richtig korrigiert. Zugegeben, wir haben an "liberty" gedacht, als wir "liberdi" geschrieben haben. "liberal" ist aber auch eine gute Wahl.

In [44]:
words = []
with open("british-english.txt") as fh:
    for line in fh:
        word = line.strip()
        words.append(word)

for word in ["holpful", "kundnoss", "holpposs", "thoes", "innerstand",
             "blagrufoo", "liberdi"]:
    neighbors = get_neighbors(words, 
                              words, 
                              word, 
                              3,
                              distance=levenshtein)

    print("vote_distance_weights: ", vote_distance_weights(neighbors, 
                                                           all_results=False))
    print("vote_prob: ", vote_prob(neighbors))
vote_distance_weights:  ('helpful', 0.5555555555555556)
vote_prob:  ('helpful', 0.3333333333333333)
vote_distance_weights:  ('kindness', 0.5)
vote_prob:  ('kindness', 0.3333333333333333)
vote_distance_weights:  ('helpless', 0.3333333333333333)
vote_prob:  ('helpless', 0.3333333333333333)
vote_distance_weights:  ('hoes', 0.3333333333333333)
vote_prob:  ('hoes', 0.3333333333333333)
vote_distance_weights:  ('understand', 0.5)
vote_prob:  ('understand', 0.3333333333333333)
vote_distance_weights:  ('barefoot', 0.4333333333333333)
vote_prob:  ('barefoot', 0.3333333333333333)
vote_distance_weights:  ('liberal', 0.4)
vote_prob:  ('liberal', 0.3333333333333333)