Entwicklung & Code
Künstliche Neuronale Netze im Überblick 5: Trainingsschleifen und Batching
Neuronale Netze sind der Motor vieler Anwendungen in KI und GenAI. Diese Artikelserie gibt einen Einblick in die einzelnen Elemente. Der erste Teil stellt das künstliche Neuron vor. Der fünfte Teil der Serie erstellt eine vollständige Trainingsschleife, zeigt die Unterschiede zwischen dem Training mit und ohne explizite Mini-Batches und stellt schließlich Techniken wie Dropout und Gewichtsabnahme zur Verbesserung der Generalisierung vor.
Prof. Dr. Michael Stal arbeitet seit 1991 bei Siemens Technology. Seine Forschungsschwerpunkte umfassen Softwarearchitekturen für große komplexe Systeme (Verteilte Systeme, Cloud Computing, IIoT), Eingebettte Systeme und Künstliche Intelligenz.
Er berät Geschäftsbereiche in Softwarearchitekturfragen und ist für die Architekturausbildung der Senior-Software-Architekten bei Siemens verantwortlich.
Um einem Netzwerk beizubringen, seinen Verlust zu minimieren, muss man wiederholt Daten präsentieren, Vorhersagen berechnen, Fehler messen, Gradienten propagieren und Parameter aktualisieren. Dieser Berechnungszyklus bildet die Trainingsschleife. Je nach Rechenressourcen und Problemdimensionen kann man sich dafür entscheiden, den gesamten Datensatz auf einmal, eine Probe nach der anderen oder mehrere Proben, die zu Mini-Batches gruppiert sind, zu verarbeiten.
Eine grundlegende Trainingsschleife in PyTorch beginnt mit der Definition eines Datenladers, der Batches von gelabelten (Eingabe-Ziel-) Paaren liefert, der Instanziierung eines Optimierers und einer Verlustfunktion und der anschließenden Iteration über Epochen. Nachfolgend finden Sie ein vollständiges Beispiel, das Mini-Batches verwendet. Jeder Teil des Codes wird ausführlich erklärt.
import torch
import torch.nn as nn
import torch.optim as optim
from torch.utils.data import DataLoader, TensorDataset
# Angenommen, wir haben einen Merkmals-Tensor X der Form (1000, 20) und einen Ziel-Tensor y der Form (1000,)
dataset = TensorDataset(X, y)
# Erstellen Sie einen DataLoader, der Batches der Größe 32 ausgibt und jede Epoche mischt
data_loader = DataLoader(dataset, batch_size=32, shuffle=True)
model = SimpleMLP(input_dim=20, hidden_dim=50, output_dim=1)
loss_fn = nn.MSELoss()
optimizer = optim.SGD(model.parameters(), lr=0.01, momentum=0.9)
num_epochs = 20
for epoch in range(num_epochs):
epoch_loss = 0.0
# Iterieren Sie über den Datensatz in Mini-Batches
for batch_inputs, batch_targets in data_loader:
# Setzen Sie die aus dem vorherigen Schritt akkumulierten Gradienten auf Null
optimizer.zero_grad()
# Berechnen Sie die Modellvorhersagen für den aktuellen Batch
batch_predictions = model(batch_inputs)
# Berechne den Verlust zwischen Vorhersagen und tatsächlichen Zielen
loss = loss_fn(batch_predictions, batch_targets)
# Backpropagation durch das Netzwerk, um Gradienten zu berechnen
loss.backward()
# Aktualisiere die Modellparameter basierend auf den Gradienten
optimizer.step()
# Akkumuliere den Verlustwert für die Berichterstellung
epoch_loss += loss.item() * batch_inputs.size(0)
# Teile durch die Gesamtzahl der Samples, um den durchschnittlichen Verlust zu erhalten
epoch_loss /= len(dataset)
print(f"Epoch {epoch+1}/{num_epochs}, Verlust: {epoch_loss:.4f}")
Der Code fasst zunächst die Merkmals- und Ziel-Tensoren in einem TensorDataset zusammen, das jede Eingabe mit der entsprechenden Beschriftung verknüpft. Anschließend erstellt er einen DataLoader, der in jeder Epoche Teilmengen der Daten in zufälliger Reihenfolge mit einer Größe von 32 ausgibt. Die Instanziierung des Modells, der Verlustfunktion und des Optimierers folgt den zuvor beschriebenen Mustern.
Die äußere Schleife verarbeitet die Daten für num_epochs
vollständige Durchläufe. Innerhalb dieser Schleife initialisieren wir eine laufende Summe für den Verlust der Epoche. Jedes Mal, wenn der DataLoader einen Stapel von Eingaben und Zielen ausgibt, löscht die Anwendung alle vorherigen Gradienteninformationen, indem sie optimizer.zero_grad()
aufruft. Die Berechnung von model(batch_inputs)
ruft die Vorwärtsmethode des Netzwerks auf und liefert Vorhersagen. Der Code vergleicht diese Vorhersagen mit den tatsächlichen Zielen, indem er die Verlustfunktion aufruft, die einen skalaren Tensor erzeugt.
Der Aufruf von loss.backward()
löst die automatische Differenziation von PyTorch aus, um die Gradienten des Verlusts in Bezug auf jeden lernbaren Parameter im Modell zu berechnen. Diese Gradienten werden im Attribut .grad
jedes Parameters gespeichert. Der Aufruf von optimizer.step()
ändert dann die Parameterwerte an Ort und Stelle gemäß der ausgewählten Aktualisierungsregel (in diesem Fall stochastischer Gradientenabstieg mit Momentum). Wir multiplizieren loss.item()
mit der Batchgröße, um die Summe der Verluste pro Stichprobe zu ermitteln, akkumulieren diese und dividieren sie am Ende durch die Datensatzgröße, um den durchschnittlichen Verlust für die Epoche anzugeben.
Das Training ohne explizite Mini-Batches ist möglich, indem wir den gesamten Datensatz als einen Batch (Stapel) behandeln. In diesem Fall kann man den DataLoader überspringen und schreiben:
# Alle Daten als einen einzigen Batch behandeln
optimizer.zero_grad()
predictions = model(X)
loss = loss_fn(predictions, y)
loss.backward()
optimizer.step()
Dieser Full-Batch-Ansatz liefert zwar bei jedem Schritt den tatsächlichen Gradienten, kann jedoch bei großen Datensätzen ineffizient sein und mehr Speicherplatz als verfügbar erfordern. Umgekehrt kann die Verwendung von Einzelproben-Updates (stochastisch) durch Setzen von batch_size=1
im DataLoader zu einer hohen Varianz in der Gradientenschätzung führen, was zu einer verrauschten Konvergenz führt, die jedoch leichter aus flachen lokalen Minima entkommen kann. Mini-Batches stellen einen pragmatischen Kompromiss dar, indem sie die Varianz reduzieren und gleichzeitig die Speicherbeschränkungen einhalten.
Selbst wenn wir dieses Trainingsverfahren befolgen, können große neuronale Netze die Trainingsdaten überanpassen und statt Muster zu lernen, die sich auf neue Beispiele verallgemeinern lassen, zum Rauschen führen. Um die Überanpassung zu verringern, kann man Techniken für die Regularisierung anwenden.
Gewichtsabnahme, mathematisch äquivalent zur L2-Regularisierung, fügt dem Verlust eine Strafe proportional zur quadrierten Norm der Gewichte hinzu. In der Praxis signalisiert man dem Optimierer die Gewichtsabnahme. Um beispielsweise einen Koeffizienten von 1e-4 zu jedem Parameter außer den Biases hinzuzufügen, schreibt man:
optimizer = optim.SGD(
[
{'params': model.fc1.weight, 'weight_decay': 1e-4},
{'params': model.fc2.weight, 'weight_decay': 1e-4},
{'params': model.fc1.bias, 'weight_decay': 0},
{'params': model.fc2.bias, 'weight_decay': 0}
],
lr=0.01,
momentum=0.9
)
Durch die Angabe von weight_decay
für jede Parametergruppe fügt der Optimierer weight_decay * θ
zum Gradienten jedes Gewichts hinzu und führt so effektiv die Aktualisierungsregel
θ ← θ − η ( ∂L/∂θ + λ · θ )
für jedes Gewicht θ aus, wobei λ der Gewichtsabklingkoeffizient ist.
Eine weitere leistungsstarke Regularisierungstechnik ist Dropout. Während des Trainings setzt Dropout bei jedem Vorwärtsdurchlauf zufällig einen Bruchteil p der Aktivierungen jeder Schicht auf Null, wodurch eine gegenseitige Anpassung der Neuronen verhindert wird. Zum Testzeitpunkt erfolgt die Deaktivierung von Dropout und die Skalierung der Aktivierungen um (1–p), um sie an die erwartete Größe anzupassen. In PyTorch fügt man Dropout-Schichten in die Modelldefinition ein. Um beispielsweise Dropout nach der ersten versteckten Schicht hinzuzufügen:
import torch.nn as nn
class MLPWithDropout(nn.Module):
def __init__(self, input_dim, hidden_dim, output_dim, p=0.5):
super().__init__()
self.fc1 = nn.Linear(input_dim, hidden_dim)
self.dropout = nn.Dropout(p=p)
self.relu = nn.ReLU()
self.fc2 = nn.Linear(hidden_dim, output_dim)
def forward(self, x):
x = self.fc1(x)
x = self.relu(x)
# Randomly zero a fraction p of elements during training
x = self.dropout(x)
x = self.fc2(x)
return x
Wenn sich das Modell im Trainingsmodus befindet – was sich durch den Aufruf von model.train()
sicherstellen lässt –, erfolgt bei jedem Vorwärtsdurchlauf die Auswahl einer neuen zufälligen Binärmaske, die einen Bruchteil p der Elemente in der versteckten Darstellung auf Null setzt. Durch den Aufruf von model.eval()
vor dem Auswerten der Validierungsdaten lässt sich Dropout deaktivieren und der gesamte Satz von Aktivierungen verwenden.
Über den Gewichtsabbau und Dropout hinaus kann Early Stopping vor Overfitting schützen, indem die Leistung anhand eines Holdout-Validierungssatzes überwacht und das Training abgebrochen wird, wenn sich der Validierungsverlust nicht mehr verbessert. In der Regel speichert man die Modellparameter, wenn der Validierungsverlust abnimmt, und beendet das Training, wenn über eine bestimmte Anzahl von Epochen keine Verbesserung mehr auftritt.
Mit diesen Regularisierungsstrategien kann man tiefere und breitere Netzwerke trainieren und gleichzeitig eine robuste Generalisierung aufrechterhalten. Im nächsten Kapitel werden wir auf dieser Grundlage aufbauen und konvolutionale neuronale Netzwerke, rekurrenten Netzwerke und hybride Architekturen untersuchen, die mehrere Schichttypen kombinieren.
(rme)