Von Trennlinien zu Neuronalen Netzen

Wir werden in diesem Kapitel ein Perzeptron entwickeln – einen der einfachsten Bausteine neuronaler Netze. Es soll zwei Klassen unterscheiden, deren Punkte sich im zweidimensionalen Raum durch eine Gerade trennen lassen.

Lineare Separierung

Fence as a dividing line

Bevor wir das Perzeptron programmieren, betrachten wir zunächst ein einfaches geometrisches Konzept. Wir wollen nach Geraden suchen, die zwei Punkte oder besser die Punkte zweier Klassen in der Ebene trennen können.

Wir werden dabei zunächst nur Geraden berücksichtigen, die durch den Ursprung des Koordinatensystems gehen. Den generellen Fall, also beliebige Geraden, werden wir später im Tutorial behandeln.

Man könnte sich etwa vorstellen, dass man zwei Attribute hat, die die Eigenschaften eines essbaren Objektes, wie einer Frucht, beschreiben, zum Beispiel "Süsse" und "Säuerlichkeit".

Dies könnte man als Punkte im zweidimensionalen Raum darstellen. Die X-Achse wird für die Werte der Süsse genutzt und die Y-Achse entsprechend für die Werte der Säuerlichkeit. Stellen Sie sich vor, wir hätten zwei Früchte als Punkte in dieser Ebene dargestellt, eine Orange an der Position (3.5,1.8) und eine Zitrone bei (1.1,3.9).

Nun könnten wir Trennlinien definieren, die Punkte trennen, die mehr zitronenartig oder mehr orangenartig sind.

Im folgenden Diagramm haben wir eine Zitrone und eine Orange eingetragen. Die grüne Linie trennt die beiden Punkte. Wir nehmen an, dass alle anderen Zitronen oberhalb dieser Linie sind und alle Orangen darunter.

Divion boundary

Die grüne Gerade ist definiert durch

$$y = mx$$

wobei:

m die Steigung der Geraden ist und
x die unabhängige Variable der Funktion darstellt.

Sei $P=(p_1,p_2)$ ein Punkt auf dieser Geraden. Dann gilt

$$ p_2 = mp_1. $$

Für $p_1 \neq 0$ erhalten wir daraus

$$ m = \frac{p_2}{p_1}. $$

Ein beliebiger Punkt $P'=(p'_1,p'_2)$ liegt somit genau dann auf der Geraden, wenn

$$ p'_2 = mp'_1, $$

beziehungsweise

$$ mp'_1 - p'_2 = 0. $$

Das folgende Python-Programm zeichnet ein Diagramm, das die zuvor beschriebene Situation darstellt:

import matplotlib.pyplot as plt
import numpy as np

X = np.arange(0, 7)

fig, ax = plt.subplots()

# Two points on different sides of the dividing line
orange = (3.5, 1.8)
lemon = (1.1, 3.9)

ax.plot(*orange, "o", color="darkorange", markersize=15)
ax.plot(*lemon, "o", color="gold", markersize=15)

# A point on the dividing line
point_on_line = (4, 4.5)

# Calculate the gradient
m = point_on_line[1] / point_on_line[0]

# Plot the dividing line
ax.plot(X, m * X, "g-", linewidth=3)

plt.show()
No description has been provided for this image

Es ist klar, dass ein Punkt $A=(a_1,a_2)$ nicht auf der Geraden liegt, wenn

$$ m \cdot a_1-a_2 \neq 0. $$

Wir wollen aber mehr wissen: Wir wollen bestimmen, ob ein beliebiger Punkt oberhalb oder unterhalb der Geraden liegt.

Divion boundary

Wenn ein Punkt $B=(b_1,b_2)$ unterhalb der Geraden liegt, muss es ein $\delta_B>0$ geben, sodass sich der Punkt $(b_1,b_2+\delta_B)$ auf der Geraden befindet.

Das heißt, dass gilt:

$$ m\cdot b_1-(b_2+\delta_B)=0 $$

Dies kann umgestellt werden zu:

$$ m\cdot b_1-b_2=\delta_B $$

Damit haben wir ein Kriterium dafür, dass ein Punkt unterhalb der Geraden liegt: $m\cdot b_1-b_2$ ist positiv, weil $\delta_B$ positiv ist.

Die Überlegung für einen Punkt oberhalb der Geraden ist analog. Wenn ein Punkt $A=(a_1,a_2)$ oberhalb der Geraden liegt, muss es ein $\delta_A>0$ geben, sodass sich der Punkt $(a_1,a_2-\delta_A)$ auf der Geraden befindet.

Das heißt, dass gilt:

$$ m\cdot a_1-(a_2-\delta_A)=0 $$

Dies kann wiederum umgestellt werden zu:

$$ m\cdot a_1-a_2=-\delta_A $$

Da $\delta_A>0$ ist, gilt also:

$$ m\cdot a_1-a_2<0 $$

Zusammengefasst können wir sagen:

Ein Punkt $P=(p_1,p_2)$ liegt

  • unterhalb der Geraden, wenn $m\cdot p_1-p_2>0$,
  • auf der Geraden, wenn $m\cdot p_1-p_2=0$,
  • oberhalb der Geraden, wenn $m\cdot p_1-p_2<0$.

Wir können dies nun bei unseren Früchten überprüfen. Die Zitrone hat die Koordinaten $(1.1,3.9)$ und die Orange die Koordinaten $(3.5,1.8)$. Der Punkt auf der Geraden, den wir verwenden, um unsere Trennlinie zu definieren, hat die Koordinaten $(4,4.5)$. Somit ist die Steigung

$$ m=\frac{4.5}{4}=1.125. $$

Diese Bedingungen können wir nun direkt in Python überprüfen. Dazu berechnen wir für beide Früchte den Ausdruck

$$ m \cdot p_1 - p_2. $$

Ein positives Ergebnis bedeutet, dass der Punkt unterhalb der Geraden liegt, ein negatives Ergebnis, dass er oberhalb der Geraden liegt.

lemon = (1.1, 3.9)
orange = (3.5, 1.8)
m = 4.5 / 4

# check if orange is below the line,
# positive value is expected:
print(orange[0] * m - orange[1])

# check if lemon is above the line,
# negative value is expected:
print(lemon[0] * m - lemon[1])
2.1375
-2.6624999999999996

Wir haben die grüne Linie nicht mit Hilfe eines mathematischen Verfahrens bestimmt, sondern sie lediglich durch Betrachten des Diagramms gewählt. Wir hätten ebenso gut viele andere Geraden verwenden können.

Das folgende Python-Programm berechnet und zeichnet einige solcher Geraden. Sie verlaufen alle durch den Ursprung, also durch den Punkt $(0,0)$.

Die rot dargestellten Geraden eignen sich nicht zur Trennung der beiden Früchte, da sich Zitrone und Orange jeweils auf derselben Seite der Geraden befinden.

Aber auch bei den grünen Geraden ist sofort erkennbar, dass sie möglicherweise keine besonders gute Trennung liefern würden, wenn wir nicht nur diese beiden Früchte, sondern viele verschiedene Zitronen und Orangen betrachten würden. Einige Zitronen könnten durchaus süßer sein, während manche Orangen ziemlich sauer sein könnten.

Wir brauchen also ein Verfahren, mit dem wir aus einer größeren Menge von Beispielen eine möglichst geeignete Trennlinie bestimmen können.

import numpy as np
import matplotlib.pyplot as plt

orange = (3.5, 1.8)
lemon = (1.1, 3.9)

fig, ax = plt.subplots()

ax.set_xlabel("sweetness")
ax.set_ylabel("sourness")

x_min, x_max = -1, 7
y_min, y_max = -1, 8

ax.set_xlim(x_min, x_max)
ax.set_ylim(y_min, y_max)

X = np.arange(x_min, x_max, 0.1)

# Try lines with different slopes.
for m in np.arange(0.1, 4.1, 0.15):
    Y = m * X

    orange_position = m * orange[0] - orange[1]
    lemon_position = m * lemon[0] - lemon[1]

    # Different signs mean that the fruits
    # are on different sides of the line.
    if orange_position * lemon_position < 0:
        ax.plot(X, Y, "g-", linewidth=0.8, alpha=0.9)
    else:
        ax.plot(X, Y, "r-", linewidth=0.8, alpha=0.9)

ax.plot(*orange, "o", color="darkorange", markersize=10)
ax.plot(*lemon, "o", color="gold", markersize=10)

plt.show()
No description has been provided for this image

Im Prinzip haben wir mit unseren Trennlinien bereits eine Klassifizierung durchgeführt, auch wenn man sie in dieser einfachen Form kaum so bezeichnen würde.

Man kann sich leicht vorstellen, dass wir nicht nur eine Orange und eine Zitrone hätten, sondern viele Orangen und Zitronen mit unterschiedlichen Werten für Süße und Säure. Dann hätten wir zwei Klassen: die Orangen und die Zitronen, wie im folgenden Diagramm dargestellt.

Two clusters of 2-dimensional points

Lassen Sie uns nun mit einem Python-Programm eine größere Anzahl von Orangen und Zitronen „erzeugen“. Dazu verwenden wir make_blobs aus sklearn.datasets. Die Punkte werden zufällig um zwei vorgegebene Mittelpunkte verteilt. Der Parameter cluster_std bestimmt dabei die Streuung um diese Mittelpunkte.

Im Programm kodieren wir die Orangen mit dem Label 0 und die Zitronen mit dem Label 1.

Wir wählen die Daten bewusst so, dass die beiden Klassen durch eine Gerade durch den Ursprung getrennt werden können. Das erlaubt uns, uns zunächst vollständig auf die Steigung der Trennlinie zu konzentrieren.

import numpy as np
import matplotlib.pyplot as plt
from sklearn.datasets import make_blobs

# 0 = orange, 1 = lemon
fruit_data, fruit_labels = make_blobs(
    n_samples=200,
    centers=[(5, 2), (2, 5)],
    cluster_std=[0.6, 0.6],
    random_state=42
)

oranges = fruit_data[fruit_labels == 0]
lemons = fruit_data[fruit_labels == 1]

oranges_x, oranges_y = oranges[:, 0], oranges[:, 1]
lemons_x, lemons_y = lemons[:, 0], lemons[:, 1]

X = np.linspace(0, 8, 100)
fig, ax = plt.subplots()

ax.scatter(oranges_x, oranges_y,
           color="darkorange", label="oranges")
ax.scatter(lemons_x, lemons_y,
           color="gold", label="lemons")

# A dividing line chosen by eye
ax.plot(X, 0.9 * X, "g-", linewidth=2)

ax.set_xlabel("sweetness")
ax.set_ylabel("sourness")
ax.set_xlim(0, 8)
ax.set_ylim(0, 8)
ax.legend()
ax.grid()
plt.show()
No description has been provided for this image

Automatisches Finden der Trennlinie

Auch die Trennlinie im letzten Diagramm wurde wieder nach dem optischen Eindruck gewählt. Es stellt sich daher die Frage: Wie können wir eine geeignete Trennlinie systematisch aus den Daten bestimmen?

Wir betrachten weiterhin nur Geraden durch den Ursprung,

$$ y=mx, $$

die allein durch ihre Steigung $m$ festgelegt sind.

Bevor wir einen Algorithmus auf viele Früchte anwenden, betrachten wir zunächst eine einzige Fehlklassifikation. Dadurch lässt sich die Grundidee einer Korrektur besonders leicht nachvollziehen.

import matplotlib.pyplot as plt
import numpy as np

def plot_fruits(p1, p2, point_on_line=(5, 1)):
    X = np.arange(0, 7)
    fig, ax = plt.subplots()

    ax.plot(p1[0], p1[1], "o",
            color="darkorange", markersize=15)
    ax.annotate("orange", xy=p1,
                xytext=(p1[0] + 0.5, p1[1] + 0.5),
                arrowprops=dict(facecolor="darkorange", shrink=0.05))

    ax.plot(p2[0], p2[1], "o",
            color="gold", markersize=15)
    ax.annotate("lemon", xy=p2,
                xytext=(p2[0] - 0.5, p2[1] - 0.5),
                arrowprops=dict(facecolor="goldenrod", shrink=0.05))

    ax.plot(*point_on_line, "x",
            color="black", markersize=12,
            label="point on line")

    m = point_on_line[1] / point_on_line[0]
    ax.plot(X, m * X, "g-", linewidth=3,
            label=f"m = {m:.3f}")

    ax.set_xlim(0, 7)
    ax.set_ylim(0, 5)
    ax.set_xlabel("sweetness")
    ax.set_ylabel("sourness")
    ax.grid(True)
    ax.legend()
    plt.show()

orange = (4, 2)
lemon = (1, 3)
point = (5, 1)
plot_fruits(orange, lemon, point)
No description has been provided for this image

Diese Gerade eignet sich nicht als Trennlinie, denn sowohl die Orange als auch die Zitrone liegen oberhalb der Geraden. Für die Orange können wir dies unmittelbar mit unserem bereits hergeleiteten Kriterium überprüfen.

m = point[1] / point[0]
orange_position = m * orange[0] - orange[1]
print("position of orange:", orange_position)
position of orange: -1.2

Das Ergebnis ist negativ. Die Orange liegt also oberhalb der Geraden, obwohl eine Orange nach unserer Konvention unterhalb der Trennlinie liegen soll.

Eine sehr direkte Korrektur besteht darin, die Gerade knapp oberhalb dieser Orange verlaufen zu lassen. Wir wählen dazu einen kleinen vertikalen Sicherheitsabstand $\delta>0$. Eine Gerade durch den Punkt

$$ (4,2+\delta) $$

hat die Steigung

$$ m_{\mathrm{Ziel}}=\frac{2+\delta}{4}. $$

Für $\delta=0.3$ liegt die Orange anschließend unterhalb der korrigierten Geraden.

delta = 0.3
plot_fruits(orange, lemon, point_on_line=(4, 2 + delta))

new_slope = (2 + delta) / 4
print("new slope:", new_slope)
print("position of orange after correction:",
      new_slope * orange[0] - orange[1])
No description has been provided for this image
new slope: 0.575
position of orange after correction: 0.2999999999999998

Wir können diese Korrektur auch als Fehler der Steigung formulieren. Ist $m_{\mathrm{Anfang}}$ die bisherige und $m_{\mathrm{Ziel}}$ die gewünschte Steigung, dann gilt

$$ \mathrm{Fehler}=m_{\mathrm{Ziel}}-m_{\mathrm{Anfang}}. $$

Bei einer vollständigen Korrektur setzen wir damit

$$ m_{\mathrm{neu}} =m_{\mathrm{Anfang}}+\mathrm{Fehler} =m_{\mathrm{Ziel}}. $$

Hier taucht bereits eine zentrale Idee des späteren Lernens auf: Wir berechnen einen Fehler und verwenden ihn, um einen Parameter unseres Modells zu verändern. Beim Perzeptron werden wir auf genau dieses Prinzip zurückkommen.

targeted_slope = new_slope
initial_slope = point[1] / point[0]
error = targeted_slope - initial_slope

print("initial slope:", initial_slope)
print("targeted slope:", targeted_slope)
print("error:", error)
print("initial slope + error:", initial_slope + error)
initial slope: 0.2
targeted slope: 0.575
error: 0.37499999999999994
initial slope + error: 0.575

Vollständige Korrekturen bei mehreren Früchten

Nun übertragen wir diese Idee von einer einzelnen Fehlklassifikation auf mehrere Früchte. Wir erzeugen mit make_blobs zunächst nur neun Punkte, damit sich die einzelnen Korrekturen im Diagramm noch gut verfolgen lassen.

Dabei gilt weiterhin durchgehend 0 = Orange und 1 = Zitrone.

from sklearn.datasets import make_blobs

# 0 = orange, 1 = lemon
demo_data, demo_labels = make_blobs(
    n_samples=9,
    centers=[(1.5, 1.0), (1.0, 1.5)],
    cluster_std=0.12,
    random_state=42
)

fig, ax = plt.subplots()
for i, ((x, y), label) in enumerate(zip(demo_data, demo_labels)):
    color = "darkorange" if label == 0 else "gold"
    ax.scatter(x, y, color=color, edgecolor="black")
    ax.annotate(str(i), (x + 0.02, y + 0.02))

ax.set_xlim(0.5, 2.0)
ax.set_ylim(0.5, 2.0)
ax.set_xlabel("sweetness")
ax.set_ylabel("sourness")
ax.grid()
plt.show()
No description has been provided for this image

Für jeden falsch klassifizierten Punkt können wir eine Steigung berechnen, die diesen Punkt unmittelbar auf die gewünschte Seite der Geraden bringt.

Für eine Orange $(x,y)$, die fälschlicherweise oberhalb der Geraden liegt, verschieben wir die Gerade über den Punkt:

$$ m_{\mathrm{Ziel}}=\frac{y+\delta}{x}. $$

Für eine Zitrone, die fälschlicherweise unterhalb der Geraden liegt, verschieben wir die Gerade unter den Punkt:

$$ m_{\mathrm{Ziel}}=\frac{y-\delta}{x}. $$

Das folgende Programm führt diese vollständige Korrektur nacheinander für alle falsch klassifizierten Punkte aus.

def is_misclassified(slope, x, y, label):
    position = slope * x - y
    # label 0 = orange: should be below the line -> position > 0
    # label 1 = lemon:  should be above the line -> position < 0
    return ((label == 0 and position <= 0) or
            (label == 1 and position >= 0))


def target_slope(x, y, label, delta=0.05):
    if label == 0:          # orange: move line above the point
        return (y + delta) / x
    else:                   # lemon: move line below the point
        return (y - delta) / x


def adjust_completely(data, labels, slope=0.3, delta=0.05, ax=None):
    X_demo = np.linspace(0, 2.2, 100)
    counter = 0

    for (x, y), label in zip(data, labels):
        if is_misclassified(slope, x, y, label):
            slope = target_slope(x, y, label, delta)
            counter += 1
            if ax is not None:
                ax.plot(X_demo, slope * X_demo,
                        linewidth=1, alpha=0.6,
                        label=f"step {counter}")
    return slope


X_demo = np.linspace(0, 2.2, 100)
fig, ax = plt.subplots()

for i, ((x, y), label) in enumerate(zip(demo_data, demo_labels)):
    color = "darkorange" if label == 0 else "gold"
    ax.scatter(x, y, color=color, edgecolor="black")
    ax.annotate(str(i), (x + 0.02, y + 0.02))

start_slope = 0.3
ax.plot(X_demo, start_slope * X_demo,
        linewidth=2, label="start")

final_slope = adjust_completely(
    demo_data, demo_labels,
    slope=start_slope, delta=0.05, ax=ax
)

ax.plot(X_demo, final_slope * X_demo,
        "g-", linewidth=3, label="final")
ax.set_xlim(0.5, 2.0)
ax.set_ylim(0.5, 2.0)
ax.set_xlabel("sweetness")
ax.set_ylabel("sourness")
ax.grid()
ax.legend()
plt.show()

print("final slope:", final_slope)
No description has been provided for this image
final slope: 0.7813563446257347

Warum eine vollständige Korrektur problematisch sein kann

Die vorherige Strategie reagiert auf jeden Fehler vollständig: Sobald ein Punkt falsch liegt, wird die Gerade unmittelbar so verschoben, dass gerade dieser Punkt auf der gewünschten Seite liegt.

Das kann problematisch werden, wenn ein einzelner Punkt nicht zum allgemeinen Muster passt. Solche Punkte nennt man Ausreißer (outliers). Wir fügen zur Demonstration eine Orange mitten in den Bereich der Zitronen ein. Sie erhält den Index 9.

# Add an orange inside the lemon cluster.
outlier = np.array([[1.05, 1.55]])
demo_data_outlier = np.vstack([demo_data, outlier])
demo_labels_outlier = np.append(demo_labels, 0)

fig, ax = plt.subplots()

for i, ((x, y), label) in enumerate(zip(demo_data_outlier,
                                         demo_labels_outlier)):
    color = "darkorange" if label == 0 else "gold"
    ax.scatter(x, y, color=color, edgecolor="black")
    ax.annotate(str(i), (x + 0.02, y + 0.02))

ax.plot(X_demo, start_slope * X_demo,
        linewidth=2, label="start")

outlier_slope = adjust_completely(
    demo_data_outlier, demo_labels_outlier,
    slope=start_slope, delta=0.05, ax=ax
)

ax.plot(X_demo, outlier_slope * X_demo,
        "g-", linewidth=3, label="final")
ax.set_xlim(0.5, 2.0)
ax.set_ylim(0.5, 2.0)
ax.set_xlabel("sweetness")
ax.set_ylabel("sourness")
ax.grid()
ax.legend()
plt.show()

print("final slope with outlier:", outlier_slope)
No description has been provided for this image
final slope with outlier: 1.5238095238095237

Die zusätzliche Orange zeigt die Schwäche der vollständigen Korrektur: Ein einzelner ungewöhnlicher Punkt kann eine große Änderung der Trennlinie verursachen und damit zuvor gut klassifizierte Punkte wieder auf die falsche Seite bringen.

Wir verwenden nun genau dieselben Daten noch einmal, starten wieder mit derselben Steigung und durchlaufen die Punkte in derselben Reihenfolge. Diesmal setzen wir die Steigung bei einem Fehler jedoch nicht sofort auf die Zielsteigung. Stattdessen bewegen wir sie nur um einen Teil der notwendigen Änderung.

Dazu führen wir die Lernrate (learning rate) $\eta$ ein:

$$ m_{\mathrm{neu}} = m+\eta\,(m_{\mathrm{Ziel}}-m). $$

Für $\eta=1$ erhalten wir die vollständige Korrektur des vorherigen Beispiels. Für $0<\eta<1$ bewegt sich die Gerade dagegen nur einen Teil des Weges zur Zielsteigung. Mit $\eta=0.2$ übernehmen wir beispielsweise nur 20 Prozent der jeweils notwendigen Änderung.

So können wir unmittelbar vergleichen, wie stark ein einzelner problematischer Punkt die Trennlinie bei vollständiger und bei schrittweiser Anpassung beeinflusst.

def adjust_gradually(data, labels,
                     slope=0.3,
                     learning_rate=0.2,
                     delta=0.05,
                     max_epochs=1,
                     shuffle=False,
                     random_state=42):
    """Adjust the slope gradually and record the learning process."""
    rng = np.random.default_rng(random_state)
    history = []
    error_history = []

    for epoch in range(max_epochs):
        errors = 0

        if shuffle:
            indices = rng.permutation(len(data))
        else:
            indices = np.arange(len(data))

        for index in indices:
            x, y = data[index]
            label = labels[index]

            if is_misclassified(slope, x, y, label):
                wanted = target_slope(x, y, label, delta)
                slope += learning_rate * (wanted - slope)
                history.append(slope)
                errors += 1

        error_history.append(errors)

        if errors == 0:
            return slope, epoch + 1, history, error_history, True

    return slope, max_epochs, history, error_history, False


def misclassified_indices(slope, data, labels):
    return [
        i
        for i, ((x, y), label) in enumerate(zip(data, labels))
        if is_misclassified(slope, x, y, label)
    ]


# Use exactly the same points, order and start slope as above.
gradual_outlier_slope, _, gradual_history, _, _ = adjust_gradually(
    demo_data_outlier,
    demo_labels_outlier,
    slope=start_slope,
    learning_rate=0.2,
    delta=0.05,
    max_epochs=1,
    shuffle=False
)

fig, ax = plt.subplots()

for i, ((x, y), label) in enumerate(zip(demo_data_outlier,
                                         demo_labels_outlier)):
    color = "darkorange" if label == 0 else "gold"
    ax.scatter(x, y, color=color, edgecolor="black")
    ax.annotate(str(i), (x + 0.02, y + 0.02))

ax.plot(X_demo, start_slope * X_demo,
        linewidth=2, label="start")

# Show the gradual changes during the same single pass.
for slope in gradual_history:
    ax.plot(X_demo, slope * X_demo,
            linewidth=1, alpha=0.35)

ax.plot(X_demo, gradual_outlier_slope * X_demo,
        "g-", linewidth=3, label="final")
ax.set_xlim(0.5, 2.0)
ax.set_ylim(0.5, 2.0)
ax.set_xlabel("sweetness")
ax.set_ylabel("sourness")
ax.grid()
ax.legend()
plt.show()

print("final slope with complete adjustment:", outlier_slope)
print("misclassified after complete adjustment:",
      misclassified_indices(outlier_slope,
                            demo_data_outlier,
                            demo_labels_outlier))
print()
print("final slope with gradual adjustment:", gradual_outlier_slope)
print("misclassified after gradual adjustment:",
      misclassified_indices(gradual_outlier_slope,
                            demo_data_outlier,
                            demo_labels_outlier))
No description has been provided for this image
final slope with complete adjustment: 1.5238095238095237
misclassified after complete adjustment: [6]

final slope with gradual adjustment: 0.7633503234225044
misclassified after gradual adjustment: [9]

Der direkte Vergleich zeigt den gewünschten Effekt besonders deutlich. Bei der vollständigen Korrektur wird die Gerade durch den Ausreißer stark angehoben. Der Ausreißer liegt danach zwar auf der gewünschten Seite, dafür wird jedoch ein zuvor passender regulärer Punkt falsch klassifiziert.

Bei der schrittweisen Anpassung reagiert die Gerade deutlich weniger stark auf den einzelnen Ausreißer. Nach demselben einmaligen Durchlauf bleibt in diesem Beispiel nur der Ausreißer selbst falsch klassifiziert, während das übrige Muster erhalten bleibt.

Das bedeutet jedoch nicht, dass eine kleine Lernrate einen nicht linear separierbaren Datensatz plötzlich lösbar macht. Würden wir immer wieder durch genau diese Daten laufen, müsste der Algorithmus weiterhin zwischen widersprüchlichen Anforderungen korrigieren. Die Lernrate begrenzt lediglich, wie stark ein einzelner Fehler die aktuellen Parameter in einem Schritt verändern kann.

Damit begegnen wir einem grundlegenden Prinzip des maschinellen Lernens: Ein Modell verändert seine Parameter schrittweise aufgrund der Fehler, die es bei den Trainingsdaten macht.

Für den nächsten Schritt verwenden wir zunächst wieder unseren bewusst linear separierbaren Datensatz ohne den zusätzlichen problematischen Punkt. Nun lassen wir mehrere Durchläufe (Epochen) zu, bis keine Fehlklassifikation mehr auftritt oder die maximale Zahl von Epochen erreicht ist.

Anschließend führen wir exakt denselben Lernprozess noch einmal durch, nachdem wir eine Orange mitten in den Zitronenbereich gesetzt haben. So können wir direkt vergleichen, was sich ändert, wenn die Daten nicht mehr vollständig durch eine Gerade getrennt werden können.

# Apply gradual learning to the linearly separable fruit data.
learned_slope, epochs, history, error_history, converged = adjust_gradually(
    fruit_data, fruit_labels,
    slope=0.3,
    learning_rate=0.3,
    max_epochs=50,
    shuffle=True
)

X = np.linspace(0, 8, 100)
fig, ax = plt.subplots()
ax.scatter(oranges_x, oranges_y,
           color="darkorange", label="oranges")
ax.scatter(lemons_x, lemons_y,
           color="gold", label="lemons")

# Show some intermediate lines.
for slope in history[:12]:
    ax.plot(X, slope * X, linewidth=1, alpha=0.25)

ax.plot(X, learned_slope * X,
        "g-", linewidth=3, label="learned dividing line")
ax.set_xlabel("sweetness")
ax.set_ylabel("sourness")
ax.set_xlim(0, 8)
ax.set_ylim(0, 8)
ax.grid()
ax.legend()
plt.show()

print("learned slope:", learned_slope)
print("epochs:", epochs)
print("converged:", converged)
print("misclassifications per epoch:", error_history)

# The method can also start on the other side.
slope_from_above, epochs_from_above, _, _, converged_from_above = adjust_gradually(
    fruit_data, fruit_labels,
    slope=3.0,
    learning_rate=0.3,
    max_epochs=50,
    shuffle=True
)
print("slope when starting with 3.0:", slope_from_above)
print("epochs:", epochs_from_above)
print("converged:", converged_from_above)
No description has been provided for this image
learned slope: 0.7520377022992157
epochs: 7
converged: True
misclassifications per epoch: [14, 3, 2, 1, 1, 1, 0]
slope when starting with 3.0: 1.1445966544497237
epochs: 7
converged: True

Mehrere Epochen mit einem problematischen Punkt

Beim linear separierbaren Datensatz erreicht der Algorithmus schließlich eine Epoche ohne Fehlklassifikation und kann das Training beenden.

Nun verändern wir nur einen einzigen Punkt: Wir fügen eine Orange bei

$$ (2,5) $$

hinzu. Dieser Punkt liegt mitten im Bereich der Zitronen. Für diese Orange müsste gelten

$$ 2m-5>0, $$

also

$$ m>2.5. $$

Gleichzeitig gibt es Zitronen im Datensatz, die nur bei einer wesentlich kleineren Steigung oberhalb der Geraden liegen. Damit existiert keine Gerade durch den Ursprung, die alle Punkte gleichzeitig korrekt klassifiziert.

Wir verwenden trotzdem denselben Algorithmus, dieselbe Lernrate und dieselbe maximale Zahl von Epochen. Jetzt bekommt max_epochs eine konkrete Bedeutung: Der Lernprozess muss auch dann beendet werden können, wenn keine fehlerfreie Trennlinie existiert.

# Add one deliberately problematic orange to the same data set.
problem_orange = np.array([[2.0, 5.0]])
problem_fruit_data = np.vstack([fruit_data, problem_orange])
problem_fruit_labels = np.append(fruit_labels, 0)

problem_slope, problem_epochs, problem_history, problem_errors, problem_converged = adjust_gradually(
    problem_fruit_data,
    problem_fruit_labels,
    slope=0.3,
    learning_rate=0.3,
    max_epochs=50,
    shuffle=True
)

fig, ax = plt.subplots()
ax.scatter(oranges_x, oranges_y,
           color="darkorange", alpha=0.55, label="oranges")
ax.scatter(lemons_x, lemons_y,
           color="gold", alpha=0.55, label="lemons")
ax.scatter(problem_orange[:, 0], problem_orange[:, 1],
           color="darkorange", edgecolor="black",
           marker="X", s=160, label="problem case")

ax.plot(X, problem_slope * X,
        "g-", linewidth=3,
        label="boundary after max_epochs")
ax.set_xlabel("sweetness")
ax.set_ylabel("sourness")
ax.set_xlim(0, 8)
ax.set_ylim(0, 8)
ax.grid()
ax.legend()
plt.show()

print("epochs:", problem_epochs)
print("converged:", problem_converged)
print("final slope:", problem_slope)
print("misclassifications in the last 10 epochs:",
      problem_errors[-10:])

# The errors never settle permanently at zero.
fig, ax = plt.subplots()
ax.plot(range(1, len(problem_errors) + 1), problem_errors)
ax.set_xlabel("epoch")
ax.set_ylabel("misclassifications")
ax.grid()
plt.show()
No description has been provided for this image
epochs: 50
converged: False
final slope: 1.3719624741905383
misclassifications in the last 10 epochs: [5, 8, 5, 7, 5, 6, 5, 9, 7, 6]
No description has been provided for this image

Der Unterschied zum vorherigen Versuch ist grundlegend. Beim separierbaren Datensatz wird schließlich eine Epoche mit 0 Fehlklassifikationen erreicht. Mit dem zusätzlichen problematischen Punkt gelingt das nicht: Der Algorithmus korrigiert die Steigung weiter, weil eine Verbesserung für bestimmte Punkte andere Punkte wieder auf die falsche Seite bringen kann.

Die Lernrate kann diese Änderungen kleiner oder größer machen, aber sie kann den Widerspruch in den Daten nicht beseitigen. Deshalb benötigen wir ein Abbruchkriterium wie max_epochs.

Damit haben wir bereits vor dem eigentlichen Perzeptron zwei verschiedene Situationen kennengelernt:

  • linear separierbare Daten: Der Lernalgorithmus kann konvergieren;
  • nicht vollständig linear separierbare Daten: Es gibt keine perfekte Trennlinie, daher muss das Training anderweitig beendet werden.

Die beiden Startwerte des separierbaren Beispiels zeigen außerdem, dass selbst dort unterschiedliche passende Trennlinien entstehen können.

Beim separierbaren Datensatz führen die beiden Startwerte in diesem Beispiel zu unterschiedlichen Endsteigungen. Beide Geraden trennen die Klassen korrekt. Unser einfaches Verfahren findet also eine passende Trennlinie, nicht notwendigerweise eine eindeutige oder in einem weitergehenden Sinn „optimale“ Trennlinie.

Das Ergebnis kann vom Startwert, von der Reihenfolge der Trainingspunkte und von der Lernrate abhängen. Beim problematischen Datensatz kommt noch etwas Grundsätzlicheres hinzu: Dort existiert innerhalb unseres Modells überhaupt keine fehlerfreie Lösung.

Genau diese Beobachtungen sind für den Übergang zum eigentlichen Lernalgorithmus wichtig.

Vom Trennalgorithmus zum Perzeptron

Bis jetzt haben wir nur einen einzigen Parameter verändert: die Steigung $m$ der Geraden. Ein Perzeptron beschreibt dieselbe Idee allgemeiner mit Gewichten.

Für zwei Eingabewerte $x_1$ und $x_2$ bildet ein Perzeptron zunächst die gewichtete Summe

$$ w_1x_1+w_2x_2. $$

Die Entscheidungsgrenze liegt dort, wo diese Summe den Wert 0 annimmt:

$$ w_1x_1+w_2x_2=0. $$

Für $w_2\neq0$ können wir nach $x_2$ auflösen:

$$ x_2=-\frac{w_1}{w_2}x_1. $$

Vergleichen wir dies mit

$$ y=mx, $$

erkennen wir unmittelbar:

$$ m=-\frac{w_1}{w_2}. $$

Unsere bisherige Suche nach einer geeigneten Steigung war damit bereits eine besonders einfache Form des Lernens einer Entscheidungsgrenze. Beim Perzeptron werden nun nicht mehr direkt die Steigung, sondern die Gewichte angepasst.

Da wir weiterhin nur Geraden durch den Ursprung betrachten, verwenden wir hier noch keinen Bias. Ein Bias würde es ermöglichen, die Gerade vom Ursprung weg zu verschieben.

A Neural Network with just one perceptron

Unser Netzwerk besteht nur aus einem einzigen Perzeptron mit zwei Eingängen: $x_1$ steht für die Süße und $x_2$ für die Säure. Zu jedem Eingang gehört ein Gewicht.

Zunächst setzen wir die Gewichte noch von Hand. Mit

weights=[-0.45, 0.5]

erhalten wir eine Entscheidungsgrenze mit der Steigung

$$ m=-\frac{-0.45}{0.5}=0.9. $$

Damit verwenden wir zunächst genau die Gerade, die wir weiter oben nach Augenmaß eingezeichnet hatten. Im nächsten Abschnitt werden wir die Gewichte nicht mehr vorgeben, sondern trainieren.

import numpy as np

class Perceptron:

    def __init__(self, weights):
        self.weights = np.array(weights, dtype=float)

    def __call__(self, in_data):
        weighted_input = self.weights * np.asarray(in_data)
        return weighted_input.sum()


p = Perceptron(weights=[-0.45, 0.5])

Ein Objekt dieser Klasse ist wie eine Funktion aufrufbar. Bevor wir viele Früchte klassifizieren, betrachten wir zunächst einen einzelnen Punkt. Damit sehen wir unmittelbar, was die Methode __call__ zurückliefert.

p([2.9, 4])
Ausgabe:
np.float64(0.6950000000000001)

Nun wenden wir dasselbe Perzeptron auf mehrere Orangen und Zitronen an:

print("oranges:")
for point in zip(oranges_x[:10], oranges_y[:10]):
    print(f"{p(point):.3f}", end=" ")

print("\nlemons:")
for point in zip(lemons_x[:10], lemons_y[:10]):
    print(f"{p(point):.3f}", end=" ")
oranges:
-2.049 -1.108 -1.713 -1.682 -1.399 -1.609 -1.063 -0.817 -1.176 -1.077 
lemons:
1.265 1.862 2.034 1.913 1.832 2.004 0.438 1.466 1.621 1.304 

Für die Orangen erhalten wir negative Werte, für die Zitronen positive Werte. Das Vorzeichen der gewichteten Summe kann also unmittelbar zur Klassifikation verwendet werden.

Wir überprüfen dies für den gesamten Datensatz:

from collections import Counter

evaluation = Counter()

for point in zip(oranges_x, oranges_y):
    if p(point) < 0:
        evaluation["correct"] += 1
    else:
        evaluation["wrong"] += 1

for point in zip(lemons_x, lemons_y):
    if p(point) >= 0:
        evaluation["correct"] += 1
    else:
        evaluation["wrong"] += 1

print(evaluation)
Counter({'correct': 200})

Die Gerade, für die die gewichtete Summe genau 0 ist, heißt Entscheidungsgrenze (engl. decision boundary). Für die von Hand gesetzten Gewichte können wir ihre Steigung direkt aus $-w_1/w_2$ berechnen und einzeichnen:

X = np.linspace(0, 8, 100)
fig, ax = plt.subplots()

ax.scatter(oranges_x, oranges_y,
           color="darkorange", label="oranges")
ax.scatter(lemons_x, lemons_y,
           color="gold", label="lemons")

w1, w2 = p.weights
slope = -w1 / w2
ax.plot(X, slope * X,
        "g-", linewidth=2, label="decision boundary")

ax.set_xlabel("sweetness")
ax.set_ylabel("sourness")
ax.set_xlim(0, 8)
ax.set_ylim(0, 8)
ax.grid()
ax.legend()
plt.show()

print("slope:", slope)
No description has been provided for this image
slope: 0.9

Das Trainieren eines Perzeptrons

Bisher haben wir die Gewichte so gewählt, dass wir bereits wussten, dass sie eine passende Trennlinie erzeugen. Ein lernendes Modell soll diese Werte jedoch selbst anhand von Beispielen bestimmen.

Bevor wir mit dem Training beginnen, machen wir den Datensatz etwas anspruchsvoller. Die bisherigen Orangen und Zitronen bilden zwei deutlich voneinander getrennte Gruppen. Wir ergänzen deshalb einige schwierigere Fälle nahe der Entscheidungsgrenze. Diese Punkte liegen dichter beieinander, sind aber weiterhin durch eine Gerade durch den Ursprung trennbar.

Damit können wir beobachten, wie das Perzeptron mit Punkten umgeht, bei denen die Klassenzugehörigkeit geometrisch weniger offensichtlich ist.

# Difficult cases close to the decision boundary
# 0 = orange, 1 = lemon
difficult_oranges = np.array([
    [3.8, 3.6],
    [4.0, 3.85]
])

difficult_lemons = np.array([
    [3.6, 3.8],
    [3.85, 4.0]
])

fig, ax = plt.subplots()

# Original data in the background
ax.scatter(oranges_x, oranges_y,
           color="darkorange", alpha=0.35, label="oranges")
ax.scatter(lemons_x, lemons_y,
           color="gold", alpha=0.35, label="lemons")

# Highlight the deliberately difficult cases
ax.scatter(difficult_oranges[:, 0], difficult_oranges[:, 1],
           color="darkorange", edgecolor="black", s=100,
           label="difficult oranges")
ax.scatter(difficult_lemons[:, 0], difficult_lemons[:, 1],
           color="gold", edgecolor="black", s=100,
           label="difficult lemons")

ax.set_xlabel("sweetness")
ax.set_ylabel("sourness")
ax.set_xlim(0, 8)
ax.set_ylim(0, 8)
ax.grid()
ax.legend()
plt.show()
No description has been provided for this image

Die vier hervorgehobenen Punkte liegen wesentlich näher an der möglichen Trennlinie als die meisten anderen Früchte. Trotzdem ist eine vollständige lineare Trennung noch möglich.

Nun teilen wir zunächst den ursprünglichen Datensatz in Trainings- und Testdaten. Die vier bewusst konstruierten Grenzfälle fügen wir anschließend den Trainingsdaten hinzu. Auf diese Weise stellen wir sicher, dass sich das Perzeptron beim Lernen tatsächlich mit ihnen auseinandersetzen muss.

Die Testdaten bleiben unverändert und werden während des Trainings nicht verwendet. Mit random_state=42 erhalten wir bei jedem Programmlauf dieselbe Aufteilung. stratify=fruit_labels sorgt dafür, dass Orange und Zitrone in beiden Teilmengen im gleichen Verhältnis vertreten sind.

from sklearn.model_selection import train_test_split

clean_train_data, test_data, clean_train_labels, test_labels = train_test_split(
    fruit_data,
    fruit_labels,
    test_size=0.2,
    random_state=42,
    stratify=fruit_labels
)

# Add the difficult cases to the training set only.
train_data = np.vstack([
    clean_train_data,
    difficult_oranges,
    difficult_lemons
])
train_labels = np.concatenate([
    clean_train_labels,
    np.zeros(len(difficult_oranges), dtype=int),
    np.ones(len(difficult_lemons), dtype=int)
])

# Use a fixed order so that the example is reproducible.
rng = np.random.default_rng(1)
order = rng.permutation(len(train_data))
train_data = train_data[order]
train_labels = train_labels[order]

print("clean training samples:", len(clean_train_data))
print("training samples including difficult cases:", len(train_data))
print("test samples:", len(test_data))
clean training samples: 160
training samples including difficult cases: 164
test samples: 40

Wie die Gewichtskorrektur funktioniert

Das Perzeptron liefert für jeden Punkt zunächst eine Klasse. Ist die Vorhersage falsch, berechnen wir den Fehler als Differenz zwischen Zielwert und berechnetem Wert:

$$ e=\text{target}-\text{calculated}. $$

Ist $e=0$, war die Vorhersage korrekt und wir müssen nichts verändern. Andernfalls passen wir jedes Gewicht an:

$$ w_i \leftarrow w_i+\Delta w_i. $$

Warum soll dabei auch der jeweilige Eingabewert $x_i$ eine Rolle spielen? Nehmen wir beispielsweise an, für einen Trainingspunkt gelte $x_1=0$, während $x_2>0$ ist. Dann trägt der Term

$$ w_1x_1 $$

überhaupt nichts zur gewichteten Summe bei. Eine Änderung von $w_1$ kann die Ausgabe für genau diesen Trainingspunkt also nicht korrigieren. Entscheidend ist hier nur der Beitrag mit $w_2$. Es liegt deshalb nahe, die Änderung eines Gewichts mit dem zugehörigen Eingabewert zu skalieren.

Die Richtung der Änderung ergibt sich aus dem Vorzeichen des Fehlers. Wir verwenden daher

$$ \Delta w_i=\eta\,e\,x_i, $$

wobei $\eta$ die Lernrate ist. In Vektorschreibweise lautet die gleiche Regel

$$ \Delta\mathbf{w}=\eta\,e\,\mathbf{x}. $$

Die Lernrate skaliert die Größe jedes Korrekturschritts, ähnlich wie bei unserer vorherigen Anpassung der Steigung. Eine kleine Lernrate führt zu vorsichtigeren Änderungen und benötigt typischerweise mehr Schritte; eine sehr große Lernrate reagiert stärker auf einzelne Trainingsbeispiele und kann zu einem unruhigen Lernverlauf führen.

Damit begegnet uns erneut dasselbe Grundprinzip wie bei der einzelnen Steigung: Wir berechnen einen Fehler und verwenden ihn, um die aktuellen Modellparameter zu verändern. Jetzt bestehen diese Parameter allerdings aus den Gewichten statt nur aus einer einzigen Steigung.

Wir speichern zusätzlich die Zahl der Fehlklassifikationen jeder Epoche. Dadurch können wir später unterscheiden, ob das Perzeptron tatsächlich konvergiert oder lediglich die maximale Zahl von Epochen erreicht hat.

import numpy as np
from collections import Counter

class Perceptron:

    def __init__(self, weights, learning_rate=0.1):
        self.weights = np.array(weights, dtype=float)
        self.learning_rate = learning_rate
        self.error_history = []
        self.converged_ = False

    @staticmethod
    def unit_step_function(x):
        return 0 if x < 0 else 1

    def __call__(self, in_data):
        weighted_sum = np.dot(self.weights, in_data)
        return Perceptron.unit_step_function(weighted_sum)

    def adjust(self, target_result, calculated_result, in_data):
        error = target_result - calculated_result
        if error != 0:
            correction = (error * np.asarray(in_data) *
                          self.learning_rate)
            self.weights += correction

    def train(self, data, labels, max_epochs=100):
        self.error_history = []
        self.converged_ = False

        for epoch in range(max_epochs):
            errors = 0

            for in_data, target in zip(data, labels):
                calculated = self(in_data)
                if calculated != target:
                    self.adjust(target, calculated, in_data)
                    errors += 1

            self.error_history.append(errors)

            if errors == 0:
                self.converged_ = True
                return epoch + 1

        return max_epochs

    def evaluate(self, data, labels):
        evaluation = Counter()
        for in_data, target in zip(data, labels):
            if self(in_data) == target:
                evaluation["correct"] += 1
            else:
                evaluation["wrong"] += 1
        return evaluation


# First train on the original, easier training data for comparison.
p_clean = Perceptron(weights=[0.1, 0.1], learning_rate=0.1)
clean_epochs = p_clean.train(clean_train_data, clean_train_labels,
                             max_epochs=500)

# Now train on the data containing the difficult cases.
p = Perceptron(weights=[0.1, 0.1], learning_rate=0.1)
epochs = p.train(train_data, train_labels, max_epochs=500)

print("epochs with clean data:", clean_epochs)
print("epochs with difficult cases:", epochs)
print("converged:", p.converged_)
print("training:", p.evaluate(train_data, train_labels))
print("test:", p.evaluate(test_data, test_labels))
print("weights:", p.weights)
epochs with clean data: 2
epochs with difficult cases: 51
converged: True
training: Counter({'correct': 164})
test: Counter({'correct': 40})
weights: [-2.70452452  2.80934343]

Die schwierigen Punkte ändern nicht die grundsätzliche Lösbarkeit des Problems: Die Klassen sind weiterhin linear separierbar, und das Perzeptron findet schließlich eine Trennlinie.

Sie machen das Lernen jedoch deutlich anspruchsvoller. In diesem reproduzierbaren Beispiel benötigt das Perzeptron mit dem ursprünglichen, leicht trennbaren Trainingsdatensatz nur wenige Epochen. Mit den zusätzlichen Grenzfällen sind wesentlich mehr Korrekturen notwendig, bevor eine Epoche ohne Fehlklassifikation erreicht wird.

Das ist ein wichtiger Unterschied: Schwierige Punkte nahe der Entscheidungsgrenze können das Lernen verlangsamen, ohne die Aufgabe grundsätzlich unlösbar zu machen.

Aus den gelernten Gewichten können wir wieder die Steigung der Entscheidungsgrenze berechnen:

X = np.linspace(0, 8, 100)
fig, ax = plt.subplots()

train_oranges = train_data[train_labels == 0]
train_lemons = train_data[train_labels == 1]

ax.scatter(train_oranges[:, 0], train_oranges[:, 1],
           color="darkorange", alpha=0.55, label="oranges")
ax.scatter(train_lemons[:, 0], train_lemons[:, 1],
           color="gold", alpha=0.55, label="lemons")

# Highlight the difficult cases again.
ax.scatter(difficult_oranges[:, 0], difficult_oranges[:, 1],
           color="darkorange", edgecolor="black", s=100)
ax.scatter(difficult_lemons[:, 0], difficult_lemons[:, 1],
           color="gold", edgecolor="black", s=100)

w1, w2 = p.weights
slope = -w1 / w2
ax.plot(X, slope * X,
        "g-", linewidth=3, label="learned decision boundary")

ax.set_xlabel("sweetness")
ax.set_ylabel("sourness")
ax.set_xlim(0, 8)
ax.set_ylim(0, 8)
ax.grid()
ax.legend()
plt.show()

print("weights:", p.weights)
print("slope:", slope)
No description has been provided for this image
weights: [-2.70452452  2.80934343]
slope: 0.9626891793855403

Ein echter Problemfall: keine perfekte Trennlinie

Wir greifen nun denselben Problemfall wieder auf, den wir bereits bei der einfachen Steigungsanpassung untersucht haben. Wir setzen bewusst eine Orange mitten in den Bereich der Zitronen:

$$ (2,5). $$

Für eine Orange muss nach unserer bisherigen Konvention gelten

$$ 2m-5>0, $$

also

$$ m>2.5. $$

Gleichzeitig enthält unser Trainingsdatensatz beispielsweise die Zitrone $(3.6,3.8)$. Für sie muss gelten

$$ 3.6m-3.8<0, $$

also ungefähr

$$ m<1.056. $$

Beide Bedingungen können nicht gleichzeitig erfüllt werden. Für unser Modell mit einer Geraden durch den Ursprung gibt es daher keine Steigung, die alle Punkte korrekt klassifiziert.

Ein solcher Punkt könnte ein echter ungewöhnlicher Fall, ein Messfehler oder auch ein falsch vergebenes Label sein. Für den Lernalgorithmus ist zunächst nur entscheidend, dass die Trainingsdaten nicht mehr vollständig separierbar sind. Nun können wir prüfen, ob sich das Perzeptron genauso verhält wie unser vorheriger, einfacherer Lernalgorithmus.

# An orange deliberately placed inside the lemon region.
problem_orange = np.array([[2.0, 5.0]])

problem_train_data = np.vstack([train_data, problem_orange])
problem_train_labels = np.concatenate([train_labels, [0]])

# Again use a fixed order for a reproducible demonstration.
rng = np.random.default_rng(7)
order = rng.permutation(len(problem_train_data))
problem_train_data = problem_train_data[order]
problem_train_labels = problem_train_labels[order]

p_problem = Perceptron(weights=[0.1, 0.1], learning_rate=0.1)
problem_epochs = p_problem.train(
    problem_train_data,
    problem_train_labels,
    max_epochs=100
)

print("epochs:", problem_epochs)
print("converged:", p_problem.converged_)
print("training:",
      p_problem.evaluate(problem_train_data, problem_train_labels))
print("errors in the last 10 epochs:",
      p_problem.error_history[-10:])

# Show the final state after the maximum number of epochs.
fig, ax = plt.subplots()

problem_oranges = problem_train_data[problem_train_labels == 0]
problem_lemons = problem_train_data[problem_train_labels == 1]

ax.scatter(problem_oranges[:, 0], problem_oranges[:, 1],
           color="darkorange", alpha=0.4, label="oranges")
ax.scatter(problem_lemons[:, 0], problem_lemons[:, 1],
           color="gold", alpha=0.4, label="lemons")
ax.scatter(problem_orange[:, 0], problem_orange[:, 1],
           color="darkorange", edgecolor="black",
           marker="X", s=160, label="problem case")

w1, w2 = p_problem.weights
if w2 != 0:
    problem_slope = -w1 / w2
    ax.plot(X, problem_slope * X,
            "g-", linewidth=3,
            label="boundary after 100 epochs")

ax.set_xlabel("sweetness")
ax.set_ylabel("sourness")
ax.set_xlim(0, 8)
ax.set_ylim(0, 8)
ax.grid()
ax.legend()
plt.show()

# Errors per epoch reveal that the learning process never reaches zero.
fig, ax = plt.subplots()
ax.plot(range(1, len(p_problem.error_history) + 1),
        p_problem.error_history)
ax.set_xlabel("epoch")
ax.set_ylabel("misclassifications")
ax.grid()
plt.show()
epochs: 100
converged: False
training: Counter({'correct': 162, 'wrong': 3})
errors in the last 10 epochs: [6, 2, 6, 3, 7, 6, 2, 2, 7, 7]
No description has been provided for this image
No description has been provided for this image

Hier verhält sich das Perzeptron grundsätzlich anders als bei den lediglich schwierigen Grenzfällen. Nach max_epochs=100 steht converged weiterhin auf False, und die Zahl der Fehlklassifikationen erreicht nicht dauerhaft 0.

Das Perzeptron korrigiert seine Gewichte immer wieder, weil eine Verbesserung für bestimmte Punkte andere Punkte erneut auf die falsche Seite der Geraden bringen kann. Die Lernrate kann die Größe dieser Änderungen beeinflussen, aber sie kann den mathematischen Widerspruch in den Daten nicht beseitigen.

Damit wird auch klar, warum max_epochs wichtig ist: Bei nicht separierbaren Trainingsdaten brauchen wir ein Abbruchkriterium, sonst könnte der Lernprozess unbegrenzt weiterlaufen.

Für die abschließende Visualisierung des normalen Lernvorgangs verwenden wir wieder den Datensatz mit den schwierigen, aber noch korrekt separierbaren Grenzfällen.

Schauen wir uns den Lernvorgang abschließend „in Bewegung“ an. Jede eingezeichnete Gerade entspricht einem Zustand der Gewichte nach einer notwendigen Korrektur.

import matplotlib.pyplot as plt
import matplotlib.cm as cm

p_anim = Perceptron(weights=[0.1, 0.1], learning_rate=0.1)

changes = []
for epoch in range(100):
    errors = 0
    for index, (in_data, target) in enumerate(zip(train_data, train_labels)):
        calculated = p_anim(in_data)
        if calculated != target:
            p_anim.adjust(target, calculated, in_data)
            changes.append((index, target, in_data.copy(),
                            p_anim.weights.copy()))
            errors += 1
    if errors == 0:
        break

fig, ax = plt.subplots()
ax.scatter(oranges_x, oranges_y,
           color="darkorange", alpha=0.18)
ax.scatter(lemons_x, lemons_y,
           color="gold", alpha=0.18)

# The difficult training cases are shown more clearly.
ax.scatter(difficult_oranges[:, 0], difficult_oranges[:, 1],
           color="darkorange", edgecolor="black", s=80)
ax.scatter(difficult_lemons[:, 0], difficult_lemons[:, 1],
           color="gold", edgecolor="black", s=80)

colors = cm.rainbow(np.linspace(0, 1, max(1, len(changes))))

# Plot only a selection if there are many updates, so the figure stays readable.
if len(changes) <= 20:
    selected = list(range(len(changes)))
else:
    selected = np.linspace(0, len(changes) - 1, 20, dtype=int)
    selected = sorted(set(selected))

for counter in selected:
    index, target, point, weights = changes[counter]
    color = "darkorange" if target == 0 else "gold"
    ax.scatter(point[0], point[1], color=color, edgecolor="black")
    ax.annotate(str(counter), (point[0], point[1]))

    w1, w2 = weights
    if w2 != 0:
        current_slope = -w1 / w2
        ax.plot(X, current_slope * X,
                color=colors[counter],
                alpha=0.65,
                label=str(counter))

ax.set_xlabel("sweetness")
ax.set_ylabel("sourness")
ax.set_xlim(0, 8)
ax.set_ylim(0, 8)
ax.grid()
ax.legend(title="weight update")
plt.show()

print("number of weight updates:", len(changes))
print("epochs:", epoch + 1)
No description has been provided for this image
number of weight updates: 112
epochs: 51

Jeder nummerierte Punkt hat in diesem Durchlauf eine Änderung der Gewichte ausgelöst. Die zugehörigen Geraden zeigen, wie sich die Entscheidungsgrenze schrittweise verändert.

Wir haben damit drei unterschiedliche Situationen kennengelernt:

  • leicht trennbare Daten: Das Perzeptron findet sehr schnell eine passende Entscheidungsgrenze,
  • schwierige, aber noch trennbare Grenzfälle: Das Lernen kann deutlich länger dauern, konvergiert aber weiterhin,
  • nicht vollständig trennbare Daten: Die Gewichte werden immer wieder korrigiert, ohne dass eine fehlerfreie Entscheidungsgrenze erreicht werden kann.

Damit sehen wir nicht nur, wie ein Perzeptron lernt, sondern auch eine seiner grundlegenden Grenzen.