Neuronale Netzwerke sind die Basis vieler moderner KI-Anwendungen, von Bilderkennung bis hin zu großen Sprachmodellen (Large Language Models). In diesem Beitrag wird Schritt für Schritt erklärt, wie neuronale Netzwerke funktionieren und wie man sie verwenden kann.
Perzeptron
Ein Perzeptron ist das einfachste Modell eines neuronalen Netzwerks. Es wurde von Frank Rosenblatt bereits in den 1950er Jahren entwickelt. Streng genommen gar kein neuronales „Netzwerk“, da es nur aus einem einzigen Neuronen besteht.
Ein Neuron
Ein Perzeptron nimmt mehrere Eingabedaten x1, x2, … xn und gewichtet sie mit Parametern w1, w2, …, wn (Gewichte). Zusätzlich wird ein Biasterm b verwendet. Daraus ergibt sich eine gewichtete Summe. Diese wird mithilfe einer Aktivierungsfunktion transformiert, sodass das Neuron am Ende genau einen Wert ausgibt. Im ursprünglichen Perzeptron-Modell war das immer der Wert 0 oder 1. In modernen neuronalen Netzen können das unterschiedliche Werte sein, abhängig von der gewählten Aktivierungsfunktion.
Mathematisch kann es so formuliert werden:
\(y = f(z) = f \left( \sum\limits_{i=1}^n w_i x_i + b \right)\)Die Aktivierungsfunktion
Die Aktivierungsfunktion bestimmt, wie die Eingabedaten eines Neurons verarbeitet werden. Dabei können unterschiedliche Funktionen verwendet werden:
- Sigmoid-Funktion:
- ReLU (Recitified Linear Unit)
Beispiel für Klassifikation
Nehmen wir an, wir haben zwei Eingabewerte x1 = 2 und x2 = 6 die in einem Neuron mithilfe einer Sigmoidfunktion transformiert werden. Für die Gewichte nehmen wir die Werte w1 = 0.5 und w2 = -0.2 an, der Biasterm b hat den Wert 1.
Daraus ergibt sich:
\(z = (0.5 \cdot 2) + (-0.2 \cdot 6) + 1 = 0.8\)Diesen Wert setzen wir nun in die Sigmoidfunktion ein:
\(f(z) = \frac {1} {1 + e^{-0.8}} = \frac {1} {1 + 0.449} \approx 0.69 \)Beispiel für Regression
Bei einem Regressionsproblem verwenden wir statt der Sigmoidfunktion eine lineare Aktivierung in der Ausgabeschicht (also keine Aktivierung). Die Berechnung bleibt identisch, jedoch bleibt , sodass das Ergebnis direkt als numerischer Wert interpretiert werden kann.
Optimierung der Parameter mit dem Gradientenabstieg
Zur Optimierung der Parameter (also der Gewichte) wird das Gradientenabstiegsverfahren verwendet. Eine genauere Erklärung dazu findet sich hier. Als Kostenfunktion wird dabei für Klassifikationsprobleme meist die Binary Cross-Entropy (BCE) verwendet, bei Regressionsproblemen der Mean Squared Error (MSE).
Die Binary Cross-Entropy (BCE) lautet:
\(Loss = \frac {1} {N} \sum\limits_{i=1}^N (y_{true,i} log(y_{true,i}) + (1-y_{true,i}) log(1-y_{true,i}))\)Sie misst also, wie gut die vorhergesagte Wahrscheinlichkeit mit der tatsächlichen Klasse übereinstimmt. Die Logarithmen dienen dazu, dass falsche Aussagen, die aber mit einer hohen Wahrscheinlichkeit getroffen wurden, stärker bestraft werden. Liegt der tatsächliche Wert beispielsweise bei 1 und der vorhergesagte Wert bei 0,1 dann ergibt sich:
\(Loss=−(1⋅log(0.1)+(1−1)⋅log(1−0.1))\\Loss=−log(0.1)=2.30\)
Als Ableitung der BCE-Kostenfunktion ergibt sich:
\(\frac {\partial Loss}{\partial \omega_j }= (y_{pred}-y_{true})\cdot x_j\)Die MSE-Kostenfunktion lautet:
\(Loss = \frac {1} {N} \sum\limits_{i=1}^N (y_{true,i} – y_{pred,i})^2\)Die partielle Ableitung nach jedem Gewicht ist dann:
\(\frac {\partial Loss}{\partial \omega_j }= (y_{pred}-y_{true})\cdot x_j\)Die Ableitungen sehen auf den ersten Blick ähnlich aus, aber während MSE eine lineare Fehlerableitung ergibt, berücksichtigt BCE die nicht-lineare Ableitung der Sigmoid-Funktion. In der Praxis wird für Klassifikationen BCE bevorzugt, da MSE bei extremen Werten zu kleinen Gradienten führt und das Training verlangsamen kann.
Beispiel für beide Fälle
Wir bleiben bei unserem vorherigen Beispiel, wonach das Ergebnis mit den aktuellen Gewichten 0.69 betragen hat. Nehmen wir an der reale Wert für diese Eingabe lag bei 1. Dann ergeben sich folgende Werte für die Gradienten:
\(\frac {\partial Loss}{\partial \omega_1 }= (0.69 – 1) \cdot 2 = -0.62 \\\frac {\partial Loss}{\partial \omega_2 }= (0.69 – 1) \cdot 6 = -1,86 \\
\frac {\partial Loss}{\partial b }= (0.69 – 1) = -0.31\)
Bei einer Lernrate von 0.1 ergeben sich somit folgende neuen Werte für die Parameter:
\(\omega_1 = 0.5-0.1(-0.62) = 0.562\\\omega_2 = -0.2-0.1(-1.86) = -0.014\\
b = 1-0.1(-0.31) = 1.031\\\)
Python
In Python lässt sich das relativ einfach implementieren. In diesem Beispiel verwenden wir einen Datensatz mit sechs Trainingsbeispielen und jeweils zwei Variablen. Die Parameter (Gewichte und bias) werden zufällig initialisiert. Die Lernrate alpha beträgt 0.1 und wir trainieren über 1000 Epochen.
Unterschied zum vorherigen Beispiel: In dieser Implementierung gehen wir von einer Klassifikation aus. Das bedeutet, dass die y-Werte 0 oder 1 betragen, also eine Klasse symbolisieren, keine Regression. Für diesen Fall ist MSE keine geeignete Kostenfunktion, stattdessen verwendet man Binary Cross-Entropy (BCE) welche speziell für Klassifikationen besser geeignet ist.
import numpy as np
# Lineare Daten (separierbar)
X = np.array([[0, 0], [1, 1], [2, 2], [3, 3], [4, 4], [5, 5]])
y = np.array([0, 0, 0, 1, 1, 1]).reshape(-1, 1) # Labels als Spaltenvektor
# Bias-Spalte zu X hinzufügen
X = np.hstack([np.ones((X.shape[0], 1)), X]) # X hat nun 3 Spalten (Bias, x1, x2)
# Parameter initialisieren
np.random.seed(42)
theta = np.random.randn(3, 1) # 3x1 Vektor (für Bias und zwei Features)
alpha = 0.1
epochs = 1000
# Aktivierungsfunktion (Sigmoid)
def sigmoid(z):
return 1 / (1 + np.exp(-z))
# Gradientenabstieg
for _ in range(epochs):
z = np.dot(X, theta) # (6x3) · (3x1) = (6x1)
h = sigmoid(z)
error = y - h
# Gradienten berechnen
dtheta = -np.mean(error * X, axis=0, keepdims=True).T # (3x1)
# Parameter aktualisieren
theta -= alpha * dtheta
print(f"Finale Gewichte:\n{theta}")
# Testen eines neuen Punkts (z.B. [2, 2])
x_test = np.array([1, 2, 2]) # Bias = 1 hinzufügen
prediction = sigmoid(np.dot(x_test, theta))
print(f"Vorhersage für (2,2): {prediction}")
Ein einfaches Perzeptron kann beispielsweise verwendet werden, um logische Verknüpfungen wie ODER oder UND nachzubilden. Das XOR-Problem (exklusives Oder) kann jedoch nicht mit einem einfachen Perzeptron gelöst werden, da es nicht linear separierbar ist. Hierfür benötigen wir ein mehrschichtiges neuronales Netz.
Einfaches mehrschichtiges neuronales Netz
Ein einfaches neuronales Netz besteht aus einer Eingabeschicht, einem Hidden Layer und einer Ausgabeschicht.
Das Netz
Ein mehrschichtiges neuronales Netz besteht aus mehreren Schichten, die wiederum aus mehreren Neuronen bestehen. Jedes Neuron ist dabei jeweils mit allen Neuronen der nachfolgenden (bzw. vorhergehenden) Schicht verbunden.
Das Netz besteht in unserem Fall also aus einer Eingabeschicht mit 2 Werten und einer Ausgabeschicht mit einem Wert. Da wir das Netz einfach halten wollen, verwenden wir nur eine versteckte Schicht. Wir legen fest, dass diese Schicht 5 Parameter haben soll.
Als Aktivierungsfunktion verwenden wir hier ReLU, als Kostenfunktion den MSE. Da wir ein Regressionsproblem abbilden wollen, verwenden wir die Aktivierungsfunktion nur in der mittleren Schicht, nicht in der letzten Schicht.
Backpropagation
Neu ist nun der Backpropagation-Algorithmus. Dieser ist notwendig um das Neuronale Netzwerk über mehrere Schichten hinweg zu trainieren. Er berechnet den Gradienten der Fehlerfunktion (MSE) nach den Gewichten und passt die Gewichte mithilfe des Gradientenabstiegs an. Dazu müssen wir wissen, wie stark jedes Neuron am Fehler beteiligt war, damit wir die richtigen Gewichte verändern.
Das Ergebnis der Ausgabeschicht ergibt sich anhand der gewichteten Summe aus den 5 Werten der mittleren Schicht. Den Fehler berechnen wir mithilfe der MSE-Verlustfuntion. Diese leiten wir nach Y ab und erhalten:
\(\frac {\partial Loss}{\partial y_{pred}} = 2(y_{pred} – y_{real})\)Das bedeutet, dass die 5 Gewichte der versteckten Schicht, insgesamt um genau diesen Wert angepasst werden müssen. Allerdings wollen wir die Fehler auf die einzelnen Gewichte aller verbundenen Neuronen verteilen. D.h. für jedes Gewicht wir jeweils nur ein Anteil des Fehlers verwendet. Wie hoch dieser Anteil ist berechnet sich dabei aus dem Anteil des jeweiligen Gewichts an der Summe aller Gewichte die mit diesem Neuron verbunden sind.
Für die versteckten Schichten kann der Fehler nicht direkt ermittelt werden. Hier ergibt sich der Fehler aus dem Anteil der Fehler der nachfolgenden Schicht der sich anteilig anhand der Gewichte auf das jeweilige Neuron zurückführen lässt.
import numpy as np
# Beispiel-Daten: Lineare Regression mit Rauschen
X = np.random.rand(100, 2) * 10 # 100 Datenpunkte mit 2 Features
y = 3 * X[:, 0] + 2 * X[:, 1] + np.random.randn(100) * 2 # y = 3x1 + 2x2 + Rauschen
y = y.reshape(-1, 1) # Umformung in Spaltenvektor
# Initialisierung der Gewichte und Biases
np.random.seed(42)
W1 = np.random.randn(2, 5) # 2 Eingangs-Features → 5 Neuronen in Schicht 1
b1 = np.zeros((1, 5))
W2 = np.random.randn(5, 1) # 5 Neuronen in Schicht 1 → 1 Ausgangsneuron
b2 = np.zeros((1, 1))
# Aktivierungsfunktionen
def relu(x):
return np.maximum(0, x) # ReLU für die versteckte Schicht
# Mean Squared Error (MSE)
def mse_loss(y_true, y_pred):
return np.mean((y_true - y_pred) ** 2)
# Lernrate und Epochen
alpha = 0.01
epochs = 1000
# Training mit Gradientenabstieg
for epoch in range(epochs):
# Vorwärtspropagation
z1 = np.dot(X, W1) + b1
a1 = relu(z1) # Aktivierung mit ReLU
z2 = np.dot(a1, W2) + b2 # KEINE Aktivierung in der letzten Schicht (Regression)
y_pred = z2 # Direkte Ausgabe für Regression
# Fehlerberechnung
error = y_pred - y
loss = mse_loss(y, y_pred)
# Gradientenberechnung (Backpropagation)
dW2 = np.dot(a1.T, error) / len(y)
db2 = np.mean(error, axis=0, keepdims=True)
d_hidden = np.dot(error, W2.T) * (z1 > 0) # Ableitung ReLU
dW1 = np.dot(X.T, d_hidden) / len(y)
db1 = np.mean(d_hidden, axis=0, keepdims=True)
# Parameter aktualisieren
W2 -= alpha * dW2
b2 -= alpha * db2
W1 -= alpha * dW1
b1 -= alpha * db1
# Optional: Loss alle 100 Epochen ausgeben
if epoch % 100 == 0:
print(f"Epoch {epoch}, Loss: {loss:.4f}")
# Test einer neuen Eingabe
x_test = np.array([[4, 5]]) # Neuer Datenpunkt (x1=4, x2=5)
z1_test = np.dot(x_test, W1) + b1
a1_test = relu(z1_test)
z2_test = np.dot(a1_test, W2) + b2 # KEINE Aktivierung in der letzten Schicht
y_test_pred = z2_test
print(f"\nVorhersage für (4,5): {y_test_pred[0,0]:.4f}")
Flexible Implementierung eines dreischichtigen Neuronalen Netzes
Um das Netz zu erstellen, definieren wir eine Klasse:
# Neuronales Netz-Klasse
class NeuralNetwork:
def __init__(self, input_size, hidden_size, output_size, learning_rate=0.1):
self.input_size = input_size
self.hidden_size = hidden_size
self.output_size = output_size
self.learning_rate = learning_rate
# Gewichte zufällig initialisieren
self.weights_input_hidden = np.random.uniform(-1, 1, (self.input_size, self.hidden_size))
self.weights_hidden_output = np.random.uniform(-1, 1, (self.hidden_size, self.output_size))
# Biases zufällig initialisieren
self.bias_hidden = np.random.uniform(-1, 1, (1, self.hidden_size))
self.bias_output = np.random.uniform(-1, 1, (1, self.output_size))Um das Netz zu erstellen, kann man nun diesen einfachen Aufruf verwenden, wobei die Anzahl der Parameter in den drei Schichten flexibel angegeben werden können:
nn = NeuralNetwork(input_size=2, hidden_size=4, output_size=1, learning_rate=0.5)Um das Netz zu trainieren, brauchen wir sowohl die Möglichkeit es vorwärts zu durchlaufen, also auch mithilfe des Backwardpropagation Algorithmus zu optimieren. Zusätzlich erstellen wir eine Trainingsfunktion, welche den Durchlauf über eine festgelegte Zahl an Epochen mehrmals durchführt:
def forward(self, X):
# Vorwärtspropagation
self.hidden_input = np.dot(X, self.weights_input_hidden) + self.bias_hidden
self.hidden_output = sigmoid(self.hidden_input)
self.final_input = np.dot(self.hidden_output, self.weights_hidden_output) + self.bias_output
self.final_output = sigmoid(self.final_input)
return self.final_output
def backward(self, X, y, output):
# Fehlerberechnung
error = y - output
# Gradientenberechnung für die Ausgabeebene
d_output = error * sigmoid_derivative(output)
# Fehler zurück in die versteckte Schicht propagieren
error_hidden = np.dot(d_output, self.weights_hidden_output.T)
d_hidden = error_hidden * sigmoid_derivative(self.hidden_output)
# Gewichte und Biases anpassen
self.weights_hidden_output += self.learning_rate * np.dot(self.hidden_output.T, d_output)
self.bias_output += self.learning_rate * np.sum(d_output, axis=0, keepdims=True)
self.weights_input_hidden += self.learning_rate * np.dot(X.T, d_hidden)
self.bias_hidden += self.learning_rate * np.sum(d_hidden, axis=0, keepdims=True)
def train(self, X, y, epochs=10000):
for epoch in range(epochs):
output = self.forward(X)
self.backward(X, y, output)
if epoch % 1000 == 0:
loss = np.mean(np.square(y - output))
print(f'Epoch {epoch}, Loss: {loss}')Nun können wir das Netz sehr einfach trainieren und eine Vorhersage ausführen lassen:
nn.train(X, y, epochs=10000)
print("Predictions after training:")
for i in X:
print(f"Input: {i}, Output: {nn.forward(i)}")Literatur
Wer noch besser verstehen möchte wie Neuronale Netze funktionieren, dem sei das Buch von Tariq Rahid empfohlen:
Tariq Rashid: „Neuronale Netze selbst programmieren“ (O’Reilly)