Neuronale Netze mit scikit-learn

Multi-Layer-Perceptron mit MLPClassifier

Scikit-learn enthält mit MLPClassifier ein vollständig verbundenes, vorwärtsgerichtetes neuronales Netz für Klassifikationsaufgaben. Ein Multi-Layer-Perceptron (MLP) besteht aus einer Eingabeschicht, einer oder mehreren verborgenen Schichten (hidden layers) und einer Ausgabeschicht.

Die Neuronen einer Schicht berechnen zunächst eine affine Transformation

[ z^{(l+1)} = a^{(l)} W^{(l)} + b^{(l)} ]

und wenden anschließend — außer an der Eingabeschicht — eine Aktivierungsfunktion an. Die Gewichte (W^{(l)}) und Bias-Vektoren (b^{(l)}) werden beim Training angepasst.

MLPClassifier eignet sich gut für kompakte klassische Machine-Learning-Beispiele. Für große Deep-Learning-Modelle, GPUs, Convolutional Networks oder Transformer verwendet man typischerweise spezialisierte Frameworks.

Ein wichtiger praktischer Punkt: MLPs reagieren empfindlich auf sehr unterschiedlich skalierte Eingabemerkmale. Deshalb kombinieren wir den Klassifikator in den Beispielen mit StandardScaler in einer Pipeline.

Dokumentation: MLPClassifier

Ein kleines XOR-Beispiel

Die XOR-Funktion ist nicht linear separierbar. Ein einzelnes lineares Perzeptron reicht deshalb nicht aus; eine verborgene Schicht kann die nötige nichtlineare Trennung lernen.

Wir verwenden nur vier Trainingspunkte. Für so einen winzigen Datensatz ist der Solver lbfgs gut geeignet. Die Skalierung wäre hier wegen der Werte 0 und 1 nicht zwingend nötig, zeigt aber bereits den später üblichen Aufbau als Pipeline.

import numpy as np

from sklearn.neural_network import MLPClassifier
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import StandardScaler

X_xor = np.array([
    [0.0, 0.0],
    [0.0, 1.0],
    [1.0, 0.0],
    [1.0, 1.0],
])
y_xor = np.array([0, 1, 1, 0])

xor_model = Pipeline([
    ("scaler", StandardScaler()),
    ("mlp", MLPClassifier(
        hidden_layer_sizes=(4,),
        solver="lbfgs",
        max_iter=2000,
        random_state=1,
    )),
])

xor_model.fit(X_xor, y_xor)

print("Vorhersagen:", xor_model.predict(X_xor))
print("Wahrscheinlichkeiten für Klasse 1:")
print(np.round(xor_model.predict_proba(X_xor)[:, 1], 3))
Vorhersagen: [0 1 1 0]
Wahrscheinlichkeiten für Klasse 1:
[0. 1. 1. 0.]

Die Methode predict liefert die vorhergesagte Klasse. predict_proba gibt dagegen die vom Modell geschätzten Klassenwahrscheinlichkeiten zurück.

Bei einem binären Klassifikator besitzt predict_proba(X) zwei Spalten. Welche Klasse zu welcher Spalte gehört, steht in classes_. Man sollte daher nicht grundsätzlich annehmen, dass Spalte 0 immer eine bestimmte inhaltliche Klasse bedeutet.

mlp_xor = xor_model.named_steps["mlp"]

print("Klassen:", mlp_xor.classes_)
print("Anzahl Schichten:", mlp_xor.n_layers_)
print("Output-Aktivierung:", mlp_xor.out_activation_)
Klassen: [0 1]
Anzahl Schichten: 3
Output-Aktivierung: logistic

Gewichte und Bias-Werte

Nach dem Training stehen die Gewichte in coefs_ und die Bias-Werte in intercepts_.

Für eine Verbindung von Schicht (l) nach Schicht (l+1) hat mlp.coefs_[l] die Form

(Anzahl Neuronen in Schicht l,
 Anzahl Neuronen in Schicht l+1)

Der zugehörige Bias-Vektor ist mlp.intercepts_[l].

Wichtig ist die Trennung: Der Bias wird in scikit-learn nicht als zusätzliches Gewicht eines künstlichen Eingangs mit konstantem Wert 1 gespeichert, sondern separat in intercepts_.

for layer, (weights, bias) in enumerate(
    zip(mlp_xor.coefs_, mlp_xor.intercepts_)
):
    print(
        f"Übergang {layer} -> {layer + 1}: "
        f"W.shape={weights.shape}, b.shape={bias.shape}"
    )
Übergang 0 -> 1: W.shape=(2, 4), b.shape=(4,)
Übergang 1 -> 2: W.shape=(4, 1), b.shape=(1,)
print("Gewichte zum ersten Neuron der Hidden-Schicht:")
print(mlp_xor.coefs_[0][:, 0])

print("\nBias dieses Neurons:")
print(mlp_xor.intercepts_[0][0])
Gewichte zum ersten Neuron der Hidden-Schicht:
[-1.89420336 -1.44240859]

Bias dieses Neurons:
0.8289397457669401

Die konkreten Zahlen hängen von Initialisierung, Solver und Trainingsdaten ab. Bei einem neuronalen Netz sollte man einzelne Gewichte deshalb normalerweise nicht isoliert interpretieren. Interessanter sind die Gesamtleistung des Modells und sein Verhalten auf bisher ungesehenen Daten.

Ein realistischeres Beispiel mit Trainings- und Testdaten

Das nächste Beispiel verwendet make_moons. Die beiden Klassen sind nicht durch eine Gerade trennbar. Wir erzeugen deshalb eine nichtlineare Klassifikationsaufgabe und teilen die Daten anschließend in ein Trainings- und ein Testset.

Die Pipeline sorgt dafür, dass StandardScaler ausschließlich aus den Trainingsdaten lernt. Das ist wichtig, um Data Leakage zu vermeiden.

from sklearn.datasets import make_moons
from sklearn.model_selection import train_test_split

X, y = make_moons(
    n_samples=600,
    noise=0.25,
    random_state=7,
)

X_train, X_test, y_train, y_test = train_test_split(
    X,
    y,
    test_size=0.25,
    stratify=y,
    random_state=7,
)

print("Training:", X_train.shape)
print("Test:", X_test.shape)
Training: (450, 2)
Test: (150, 2)
moon_model = Pipeline([
    ("scaler", StandardScaler()),
    ("mlp", MLPClassifier(
        hidden_layer_sizes=(50, 25),
        activation="relu",
        solver="adam",
        alpha=1e-4,
        learning_rate_init=0.005,
        max_iter=1500,
        early_stopping=True,
        validation_fraction=0.15,
        n_iter_no_change=30,
        random_state=1,
    )),
])

moon_model.fit(X_train, y_train)

print(f"Training-Accuracy: {moon_model.score(X_train, y_train):.3f}")
print(f"Test-Accuracy:     {moon_model.score(X_test, y_test):.3f}")
Training-Accuracy: 0.967
Test-Accuracy:     0.933

Die Test-Accuracy ist für die Beurteilung wesentlich aussagekräftiger als die Trainings-Accuracy. Eine sehr hohe Trainingsleistung allein sagt noch nicht, wie gut das Modell auf neue Daten generalisiert.

Mit early_stopping=True trennt MLPClassifier bei den Solvern adam und sgd intern einen Teil der Trainingsdaten als Validierungsdaten ab. Bleibt die Validierungsleistung über mehrere Iterationen ohne ausreichende Verbesserung, wird das Training beendet.

from sklearn.metrics import confusion_matrix, classification_report

y_pred = moon_model.predict(X_test)

print("Confusion Matrix:")
print(confusion_matrix(y_test, y_pred))

print("\nClassification Report:")
print(classification_report(y_test, y_pred, digits=3))
Confusion Matrix:
[[69  6]
 [ 4 71]]

Classification Report:
              precision    recall  f1-score   support

           0      0.945     0.920     0.932        75
           1      0.922     0.947     0.934        75

    accuracy                          0.933       150
   macro avg      0.934     0.933     0.933       150
weighted avg      0.934     0.933     0.933       150

Entscheidungsgrenze

Für zweidimensionale Lehrbeispiele lässt sich die vom MLP gelernte nichtlineare Entscheidungsgrenze direkt darstellen. Die Pipeline kann dabei unverändert verwendet werden; die Skalierung geschieht intern.

import matplotlib.pyplot as plt
from sklearn.inspection import DecisionBoundaryDisplay

fig, ax = plt.subplots(figsize=(8, 6))

DecisionBoundaryDisplay.from_estimator(
    moon_model,
    X_train,
    response_method="predict",
    alpha=0.25,
    ax=ax,
)

ax.scatter(
    X_train[:, 0],
    X_train[:, 1],
    c=y_train,
    edgecolors="k",
    s=30,
    label="Training",
)
ax.scatter(
    X_test[:, 0],
    X_test[:, 1],
    c=y_test,
    edgecolors="k",
    marker="^",
    s=55,
    label="Test",
)

ax.set_title("MLPClassifier: gelernte Entscheidungsgrenze")
ax.set_xlabel("$x_1$")
ax.set_ylabel("$x_2$")
ax.legend()
plt.show()
No description has been provided for this image

Lernkurve des Optimierers

Bei adam und sgd enthält loss_curve_ den Trainingsverlust pro Iteration. Wenn Early Stopping aktiv ist, stehen zusätzlich die internen Validierungsscores in validation_scores_.

Die Kurven sind diagnostische Hilfsmittel. Sie ersetzen keine unabhängige Testauswertung.

mlp_moons = moon_model.named_steps["mlp"]

plt.figure(figsize=(8, 5))
plt.plot(mlp_moons.loss_curve_)
plt.xlabel("Iteration")
plt.ylabel("Loss")
plt.title("Trainingsverlust des MLP")
plt.show()

print("Benötigte Iterationen:", mlp_moons.n_iter_)
No description has been provided for this image
Benötigte Iterationen: 72

Wichtige Hyperparameter

Einige besonders wichtige Parameter von MLPClassifier sind:

  • hidden_layer_sizes: Anzahl und Größe der verborgenen Schichten. (30, 15) bedeutet zwei Hidden Layers mit 30 beziehungsweise 15 Neuronen.
  • activation: Aktivierungsfunktion der Hidden Layers, beispielsweise "relu" oder "tanh".
  • solver: Optimierungsverfahren. adam ist ein guter allgemeiner Ausgangspunkt; lbfgs kann auf kleinen Datensätzen sehr gut funktionieren.
  • alpha: Stärke der L2-Regularisierung der Gewichte.
  • learning_rate_init: Anfangslernrate für adam und sgd.
  • max_iter: maximale Anzahl Iterationen.
  • early_stopping: beendet das Training anhand einer internen Validierungsmenge, wenn sich die Validierungsleistung nicht mehr verbessert.
  • random_state: macht zufallsabhängige Teile des Trainings reproduzierbar.

Die beste Kombination hängt vom Datensatz ab. Hyperparameter sollten anhand von Cross-Validation auf den Trainingsdaten gewählt werden — nicht anhand des endgültigen Testsatzes.

Zusammenfassung

Ein robuster scikit-learn-Workflow für ein MLP sieht typischerweise so aus:

  1. Trainings- und Testdaten sauber trennen.
  2. Vorverarbeitung und MLPClassifier in einer Pipeline kapseln.
  3. Hyperparameter nur auf den Trainingsdaten abstimmen.
  4. Die endgültige Güte einmal auf dem bisher unberührten Testsatz beurteilen.
  5. Bei stochastischem Training einen festen random_state verwenden, wenn reproduzierbare Lehrbeispiele gewünscht sind.

Scikit-learn stellt damit eine kompakte Möglichkeit bereit, klassische Multi-Layer-Perceptrons in denselben Modellwahl- und Evaluationsworkflow wie andere scikit-learn-Schätzer einzubetten.