- Startseite
- Allgemeine Informationen zur Lehrveranstaltung
- Einfaches Python Setup und Wichtige Informationen
- 0. Python Einführung und Checkup
- 1. Einführung und einfache Algorithmen
- 2. Differenzieren und Integrieren
- 3. Vektoren, Matrizen und Vektorisierung in Python
- 4. Datenanalyse bzw. Datenauswertung
- 5. Grundlagen der Optimierung und Gradient Descent
- 6. Stochastische Optimierung und Genetische Algorithmen
- 7. Monte-Carlo-Methoden – Simulation und Integration
- 8. Monte-Carlo-Methoden, Teil 2 – Monte-Carlo-Integration, Teil 2 und Random Walk
- 9. Unsupervised Machine Learning: Clustering von Daten
- 10. Supervised Machine Learning: Grundlagen
- 11. Einführung in künstliche neuronale Netzwerke
Die Jupyter-Notebooks zur Lehrveranstaltung finden Sie im zugehörigen GitHub-Repository.
11. Einführung in künstliche neuronale Netzwerke¶
In dieser Einheit kommen wir zu einem sehr populären Kapitel bzw. Werkzeug aus dem Bereich des Machine Learnings, nämlich künstlichen neuronalen Netzwerken.
Diese tauchen meist unter dem Begriff des supervised Learning auf, sie können allerdings auch beim unsupervised Learning eingesetzt werden. Wir verhalten uns hier trotzdem traditionell und organisieren uns für unsere Einheit einen gelabelten Trainingsdatensatz. Genauer gesagt werden wir sogar den gleichen Datensatz verwenden, den wir in der vorangegangenen Einheit bereits kennen gelernt haben.
Wir werden hier auch wieder ein Klassifikationsproblem) angehen, einfach weil das zugänglicher ist. In dem Setup, das wir hier aufbauen werden, ist es allerdings überhaupt kein Problem, auf ein Regressionsproblem umzusteigen, denn dafür muss man dann unten nur die Loss-Funktion austauschen – aber dazu kommen wir noch.
Der Plan für diese Einheit ist, zunächst die benötigten Packages zu installieren, dann über die grundsätzliche Struktur von künstlichen neuronalen Netzten zu reden, und danach ein einfaches Netzwerk zu trainieren und auszuwerten.
Zunächst aber noch die Imports für heute.
%matplotlib inline
import matplotlib.pyplot as plt # für plotting, wie gewohnt
import numpy as np # für numerische Aktionen mit Arrays, wie gewohnt
import sys # um System-Befehle ausführen zu können
from IPython.display import Image # um Bilder von einer URL anzuzeigen
# hier die Funktionen für den Datensatz
from sklearn.datasets import make_moons, make_circles # zur Erzeugung von Datensets
# die Pytorch- und Lightning-spezifischen Imports kommen weiter unten
11.1 Installation von PyTorch und PyTorch Lightning in Jupyter Notebook¶
Die folgenden Kommandos installieren die Packages PyTorch und PyTorch Lightning direkt über das Jupyter Notebook in jenem Anaconda-Environment, in dem Jupyter läuft. Wenn Sie das nicht möchten, dann erzeugen Sie ein eigenes conda Environment für diese Aktion, aktivieren Sie es und starten Sie dort Jupyter Notebook neu. Führen Sie erst dann die Installationsbefehle aus.
# und die Packages PyTorch und PyTorch Lightning installieren wir gleich
# hier in den aktuellen Jupyter Kernel.
# Zunächst PyTorch:
!conda install --yes --prefix {sys.prefix} pytorch torchvision torchaudio -c pytorch
Collecting package metadata (current_repodata.json): done Solving environment: done # All requested packages already installed.
# Und dann noch das Abstraction Layer PyTorch Lightning:
!conda install --yes --prefix {sys.prefix} pytorch-lightning -c conda-forge
Collecting package metadata (current_repodata.json): done Solving environment: done # All requested packages already installed.
Nach diesen beiden Aktionen sollte alles Nötige installiert sein, um das Notebook weiter auszuführen. Hier sagen die Outputs, dass bereits alle Packages installiert sind, weil ich das bereits vorher erledigt habe. Bei einer neuinstallation werden die entsprecheneden Informationen über den Installationsprozess ausgegeben, die man normalerweise bei der Einrichtung direkt in der Shell zu sehen bekommt.
11.2 Erzeugen des Datensatzes für das Training des künstlichen neuronalen Netzwerks¶
Als nächstes werden wir im Prinzip den gleichen Datensatz erzeugen wie beim letzen mal beim Supervised Learning, nur mit ordentlich mehr Punkten. Wie Sie vielleicht bereits wissen, braucht es für entsprechend große Netzwerke auch dementsprechend viel Trainingsdaten. Legen wir also los:
# Erzeuge einen mondförmigen Datensatz mit 2 Klassen (also 2 Mond-Punktwolken)
# noise bedeutet, wie sehr die Monde "zerstreut" werden, wir nehmen hier einen mittleren Wert
# der random_state sorgt wieder für Reproduzierbarkeit
raw_data = make_moons(n_samples=5000, noise=0.7, random_state=0)
# Der Output hat zwei Teile, der erste sind die Inputs
input_data = raw_data[0]
# der zweite sind die Labels
label_data = raw_data[1]
# Sehen wir uns zur Erinnerung kurz die ersten 10 Inputs an
print("Features:\n", input_data[:10])
# Und die ersten 10 Labels
print("Labels:\n", label_data[:10])
Features: [[ 0.75905709 1.56726433] [ 0.90673392 -1.10902892] [ 0.97857421 0.53220442] [ 2.30592358 0.12020829] [ 0.31471019 1.20925982] [-0.80655991 0.3538562 ] [-0.32596714 2.16884762] [-0.70630087 1.26108296] [ 0.90290597 -0.03617829] [ 0.41587006 1.14335276]] Labels: [0 1 1 1 0 1 0 0 0 1]
# Plotten wir das auch noch einmal zur Veranschaulichung
fig=plt.figure()
# setzen wir das Skalenverhältnis von x und y auf 1
ax = plt.gca()
ax.set_aspect(1)
# Ein Scatterplot, wie wir ihn schon gewohnt sind, mit Farben nach Klassen
plt.scatter(*np.transpose(input_data), c=label_data)
plt.show()
Das sieht nach einem sehr interessanten Datensatz aus. Jetzt kommen wir also zum nächsten Schritt, dem Aufsetzen des Lernens und Vorhersagens mit PyTorch. Dazu muss ich zunächst zumindest ein Bisschen ausholen:
11.3 Grundlegende Struktur eines einfachen künstlichen neuronalen Netzwerks¶
Ein einfaches künstliches neuronales Netzwerk (ab jetzt einfach kurz “KNN” genannt), besteht aus folgenden grundlegenden Elementen:
- Einem Input-Layer, in das die Inputs passen
- Einem Output-Layer, das die Outputs ausgibt
- Mehreren sogenannten “hidden” Layers dazwischen, oder einer ganz frei wählbaren Netzwerk-Topologie
- Besteht ein KNN aus mehreren hidden Layers, dann wird es bereits als “deep” bezeichnet, man nennt die entsprechende Variante von Machine Learning dann auch “Deep Learning”.
- Zwischen aufeinanderfolgenden Layers können verschiedene zusätzliche “Effekte” eingebaut werden, die zur Stabilität des Trainings und der Vorhersagen beitragen können. Damit wollen wir uns hier nicht befassen (das kommt im Laufe der Zeit von selber). Eine Sache ist aber wichtig:
- Zwischen aufeinanderfolgenden Layers kommt immer eine sogenannte “Nichtlinearität” oder “Aktivierungsfunktion”. Diese sorgt dafür, dass die Kombination von aufeinanderfolgenden Layers nicht trivial wird, denn:
Die einfachste Layer-Variante (und nur damit beschäftigen wir uns hier) nennt man “fully-connected” oder “dense” Layer. Es besteht aus einer fixen Anzahl von Einheiten, sogenannten “Neuronen”. Jedes Neuron beinhaltet einen Zahlenwert, der dadurch bestimmt wird, dass die Inputs (Zahlenwerte) aus den Neuronen des vorigen Layers linearkombiniert werden.
# Hier ein Bild dazu, Quelle: https://commons.wikimedia.org/wiki/File:MultiLayerNeuralNetworkBigger_english.png
Image(url="https://upload.wikimedia.org/wikipedia/commons/thumb/c/c2/MultiLayerNeuralNetworkBigger_english.png/880px-MultiLayerNeuralNetworkBigger_english.png", width=700)
Sie können sich das so wie eine Matrix-Vektor-Multiplikation vorstellen. Jedes fully-connected Layer ist ein Vektor von Zahlen. Und jedes Nachfolgende Layer wird daraus durch Multiplikation des vorangegangenen Vektors mit einer Matrix von Koeffizienten berechnet. Da das eine lineare Abbildung ist, wäre die Hintereinanderausführung mehrerer solcher Layers wieder linear.
Und genau daher verwendet man eine nichtlineare Aktivierung. Klassische Aktivierungsfunktionen sind z.B. der Arkustangens oder die sogenannte Sigmoid-Funktion: $$\mathrm{sig}(x)=\frac{1}{1+e^{-x}}$$ Das sieht geplottet so aus:
# Erzeuge einen Plot
fig = plt.figure()
# Erzeuge eine Reihe von x-Werten zwischen -10 und 10
x_values = np.linspace(-10, 10, 200)
# Plotte die Funktion
plt.plot(x_values, 1. / (1. + np.exp(-x_values)), label="Sigmoid")
# und dazu noch den Arkustangens
plt.plot(x_values, np.arctan(x_values), label="Arkustangens")
# Achsenbeschriftungen
plt.xlabel(r"$x$")
plt.ylabel(r"sig$(x)$")
# und die Legende erzeugen
plt.legend(loc="lower right")
plt.show()
Die Nichtlinearität sorgt im Wesentlichen dafür, dass es z.B. einen bestimmten Wertebereich für die Outputs gibt. Statt beliebiger Zahlen kommen bei der Sigmoid-Funktion Werte zwischen $0$ und $1$ heraus. Beim Arkustangens ist es ein Wertebereich von $-1$ und $1$. Sie sorgt aber auch dafür, dass das Netz die Möglichkeiten durch mehrere Layers gut nutzen kann.
Soviel ganz kurz und Grundlegend zur Struktur eines einfachen KNN. Was von all dieser Struktur wird nun aber beim Training gelernt? Was ist vorgegeben?
11.4 Freie Parameter und Hyperparameter in einem einfachen künstlichen neuronalen Netzwerk¶
Ist die Struktur eines KNN einmal festgelegt, dann folgt daraus, welche und wie viele freie, also trainierbare bzw. lernbare, Parameter in diesem Netz stecken. Dass die Anzahl der freien Parameter in einem Modell eine wichtige Größe ist, das wissen wir bereits. Im Zusammenhang mit KNNs verdient der Vergleich der Anzahlen der Parameter im Modell mit der Anzahl der Datenpunkte allerdings zusätzliche Aufmerksamkeit.
Schnell ist man versucht, das Netz tiefer und die Layers breiter zu machen, um dem Netzwerk zu ermöglichen, aller Art Strukturen in den Daten zu finden oder zu erlernen, aber dabei schießt man oft über das Ziel hinaus. Das Problem, das hier auftritt, heißt “Overfitting”, d.h., das Netz lernt einfach die Trainingsdaten auswendig. Das soll aber nicht passieren, denn sonst kann es über die Trainingsdaten hinaus (z.B. für die Testdaten) keine besonders guten Vorhersagen mehr machen. Also wenden wir uns kurz dem Thema Parameter zu.
Zunächst einmal zu den Begriffen:
- Ein freier Parameter in einem Machine-Learning-Modell wird beim Training angepasst
- Ein Hyperparameter eines Machine-Learning-Modells wird durch die Struktur oder andere Aspekte vorgegeben und ändert sich während eines Trainings normalerweise nicht
Nehmen wir z.B. ein einfaches Netzwerk her, das aus einem Input-Layer, einem Output-Layer und zwei hidden Layers besteht, alle fully-connected. Das ist also ein Layer mehr als im Bild oben, aber die Prinzipien sind die gleichen. Dann haben wir dabei allein durch die HyperparameterNetzwerk-Topologie folgende Hyperparameter:
- Die Anzahl der Neuronen im Input-Layer
- Die Anzahl der Neuronen im Output-Layer
- Die Anzahl der hidden Layers
- Die Anzahl der Neuronen im ersten hidden Layer
- Die Anzahl der Neuronen im zweiten hidden Layer
Die Anzahlen der Neuronen im Input- und Output-Layer werden grundsätzlich durch die Dimension von Input-Daten und dem gewünschten Output vorgegeben. Für unseren Beispiel-Datensatz sind das 2 Inputs ($x$ und $y$ der Punkte) und 2 Outputs (die Wahrscheinlichkeiten für die Klassen). Die Anzahlen der Neuronen in den hidden Layers können wir frei wählen. Nennen wir sie für den Moment einmal $m_1$ und $m_2$. Da die Neuronen von einem Layer zum nächsten alle miteinander verbunden sind, erhalten wir so die folgende Gesamtanzahl von freien Parametern in unserem KNN: $$2\times m_1 + m_1 \times m_2 + m_2 \times 2$$ Wenn wir z.B. $64$ Neuronen in beide hidden Layers setzen, dann werden das bereits $64\times 68= 4352$ freie Parameter. Es kann hier also, insbesondere mit fully-connected Layers, sehr schnell gehen, und man hat einen ganzen Haufen freie Parameter im Modell. Behalten wir das einmal im Hinterkopf.
11.5 Training eines künstlichen neuronalen Netzwerks im Allgemeinen¶
Was bedeutet das nun für das Training? Wir haben beim vergangenen Mal bereits supervised Machine Learning praktiziert und verschiedene Modelle per “Fit” auf unsere Daten losgelassen. Aber was ist dabei eigentlich passiert? Wenn ein KNN (oder ein anderes ML-Modell) trainiert wird, dann wird dabei versucht, ein Optimierungsproblem zu lösen. Optimierung kennen wir ja bereits aus früheren Einheiten. Wie beim supervised ML besprochen, optimieren wir hier den Unterschied der Modell-Vorhersagen zu den tatsächlichen Labels der Trainingsdaten.
Das passiert auch hier. Die Methode, die dabei angewendet wird, ist meist eine Variante von Gradient Descent, den wir auch bereits kennen. Die Schrittweite beim Gradient Descent ist ein weiterer Hyperparameter und wird als “Learning Rate” bezeichnet Die zu minimierende Funktion ist die “Kostenfunktion”, die entsteht, wenn man den sogenannten “Loss” über alle Trainingsbeispiele mittelt. Die Loss-Funktion kann man verschieden wählen, meist je nach Problemstellung, und auch diese Wahl ist eigentlich ein Hyperparameter. Wir werden sie hier weiter unten einfach aus dem PyTorch-Fundus für Loss-Funktionen auswählen.
Ein weiterer Hyperparameter ist die sogenannte “Batchsize”. Was ist das nun schon wieder? Beim Deep Learning sind die Datenmengen teils riesig. Das bedeutet unter anderem, dass sie meist in Teilen dem Modell zum Lernen “gefüttert” werden. Z.B., wenn $100$ Datenpunkte auf einmal, gemeinsam mit dem Modell, gut in den Speicher der Grafikkarte passen, dann wählt man Batchsize $100$. Damit kann man auch gut experimentieren, um den Lern-Prozess effizient zu gestalten.
Wenn alle Daten einmal durch das Modell gelaufen sind, dann spricht man von einer “Epoche”. Das passiert mehrmals, und man lässt also das Modell einige (viele, teils sehr viele) Epochen lang trainieren.
So, jetzt habe ich aber langsam genug geredet. Gehen wir’s an.
11.6 Training eines künstlichen neuronalen Netzwerks mit PyTorch und PyTorch Lightning¶
Am direktesten ist es, wenn ich hier einfach die Teile unseres kleinen Netzwerks mit PyTorch und PyTorch Lightning der Reihe nach zusammensetze, und dann starten wir das Training. Hier kommen erstmal noch eine Runde von Imports.
import torch # Die Package PyTorch selbst
import torch.nn as nn # Ein Teil nochmal extra, als Abkürzung, für Teile von neuronalen Netzen
from torch import optim # Das Modul für die Optimierungs-Algorithmen, auch extra nochmal
from torchmetrics.functional import accuracy # Die Funktion zur Berechnung der Accuracy
from torch.utils.data import Dataset, DataLoader, random_split # einige Tols für die Datenaufbereitung
import pytorch_lightning as pl # Und die Abstraction PyTorch Lightning
# So wird unten die Zelle für Aufruf und Training aussehen.
# ACHTUNG: NOCH NICHT AUSFÜHREN, das machen wir später, aber hier
# wird erst einmal klar, was wir noch brauchen
# zunächst holen wir uns von PyTorch Lightning eine Instanz der Trainer-Python-Klasse
# Die maximale Epochenzahl ist wichtig, sonst läuft der Trainer erstmal unbegrenzt
trainer = pl.Trainer(max_epochs=100)
# Als nächstes erzeugen wir eine Instanz unserer eigenen Netzwerk-Python-Klasse,
# die wir noch schreiben müssen. Das klingt zunächst nach Stress, ist
# aber sehr geradlinig, wie Sie gleich sehen werden
# Dieser Schritt ist im Prinzip analog zum Modell-Aufruf in Scikit-Learn, wie letztens
model = OurNetwork(Einpaarinputs)
# Dann rufen wir das Training auf, auch das ist analog zur vergangenen Einheit
trainer.fit(model)
# Und schließlich rufen wir den Test auf, das ist eine kleine Abkürzung im Vergleich zum
# vergangenen Setup mit Scikit-Learn, aber grundsätzlich auch das gleiche
trainer.test(model)
# das ist grundsätzlich alles.
Dieses Erste Setup wollte ich Ihnen zeigen, bevor wir die entsprechende Python-Klasse für das Netzwerk (und noch eine, ganz kurze für die leichtere Verwendung des Datensatzes) erstellen. Ich weiß schon, wir haben uns bisher nicht um Klassen in Python gekümmert, aber das ist trotzdem keine Hexerei. Denken Sie so ähnlich wie für eine Funktion in Python, nur mächtiger. Dann werden Sie schnell die Einträge hier verstehen.
PyTorch Lightning macht es sehr einfach, die Funktionalität von PyTorch zu nutzen, die selbst bereits sehr umfangreich und komfortabel ist. Fangen wir einfach mal an. Die Sache kommt trotzdem etwas umfangreich daher, aber das liegt daran, dass diese Methoden einfach sehr mächtig sind und daher auch entsprechende vorbereitung erfordern.
# Zunächst eine einfache Klasse für unseren Datensatz. Das hat den Sinn, dass
# die Daten von PyTorch Lightning einfacher verarbeitet werden können, egal,
# auf welcher Hardware und in welcher Konfiguration
class OurData(Dataset):
# In der Initialisierung weisen wir einfach unsere Daten den Inputs und Outputs zu
def __init__(self):
# Hier die Inputs. "self" bezieht sich dabei immer auf die Instanz
self.data_X = input_data
# Hier die Labels.
self.data_y = label_data
# Schreiben wir die Länge der geladenen Daten heraus
print("Successfully created training data of length: ", len(self.data_X))
def __len__(self):
# Diese Methode definiert, was ausgegeben wird, wenn nach der Länge des Datensatzes gefragt wird
return len(self.data_X)
def __getitem__(self, idx):
# Diese Methode definiert, wie man das nächste Element des Datensatzes erhält
return self.data_X[idx], self.data_y[idx]
# Nun die für unsere Zwecke recht umfangreiche Klasse für das KNN
# Hier kommen einige Methoden, die die Funktionen beim Training und weiteren Schritten
# vorbereiten und dann im Detail beschreiben und definieren
# Klassennamen verwenden üblicherweise Großbuchstaben am Anfang von Wortteilen
class OurNetwork(pl.LightningModule):
"""
Einfaches Pytorch-Lightning-Modul, das aus einem x,y-Koordinatenpaar eine Klasse
für einen Datensatz mit 2 Mond-Punktwolken vorhersagt
"""
# diese Methode initialisiert wieder die Klasse
# "self" steht dabei wieder für die Instanz, wenn diese einmal erstellt ist
# alle mit self. referenzierten Variablen können auch problemlos innerhalb
# der Klasse verwendet werden
# wir verwenden hier zwei Variablen, die beim Instanz-Erzeugen übergeben werden
def __init__(self, batch_size=100, hidden_dim=16):
# diese Klasse basiert auf einer anderen ("LightningModule")
# hier laden wir deren init Methode, damit erbt die Klasse auch
# alles, was die übergeordnete (parent) Klasse kann
super().__init__()
# hier definieren wir die Batchsize aus der Eingabe-Variablen
self.batch_size = batch_size
# Hier definieren wir nun die Layers zur Verwendung innerhalb der Instanz
# nn.Linear ist ein fully-connected Layer, das in_features Inputs und
# out_features Outputs hat
# Hier ist das Input-Layer bzw. die Parameter-Matrix vom Input- zum ersten hidden Layer
self.fc_1 = nn.Linear(in_features=2, out_features=hidden_dim)
# Hier ist die Parameter-Matrix vom ersten zum zweiten hidden Layer
self.fc_2 = nn.Linear(in_features=hidden_dim, out_features=hidden_dim)
# Hier ist die Parameter-Matrix vom zweiten zum dritten hidden Layer,
# falls wir das später einführen und verwenden wollen
self.fc_3 = nn.Linear(in_features=hidden_dim, out_features=hidden_dim)
# Und hier die Parameter-Matrix vom letzten hidden Layer zum Output Layer
# Das Output Layer hat zwei outputs, die den Wahrscheinlichkeiten für
# die beiden Klassen entsprechen
self.fc_4 = nn.Linear(in_features=hidden_dim, out_features=2)
# Hier die Loss-Funktion. Wir wählen Cross-Entropy, eine gute
# Möglichkeit für ein Klassifikationsproblem
self.loss = nn.CrossEntropyLoss()
# Noch ein Demo-Input (das muss nicht unbedingt sein, aber es ist praktisch,
# falls man etwas testen will)
self.example_input_array = torch.zeros(self.batch_size, 2)
# Hier starten wir noch Listen fürs Plotten hinterher
# Einmal die Loss-Funktion auf dem Training-Set
self.train_loss = []
# Und die Loss-Funktion auf dem Validierungs-Set
self.val_loss = []
# Hier endet die Init-Funktion der Klasse
return
# Jetzt kommt die zentrale Methode der Klasse, in der definiert wird,
# wie ein Datenpunkt (genauer gesagt, ein Batch von Datenpunkten)
# durch das Netz von vorne bis hinten durchgereicht wird
# Hier können Sie beliebige Netzwerke konstruieren, mit allem, was PyTorch
# so an Layern und anderen Dingen bietet. Wir starten einmal simpel:
# Nur ein paar fully-connected Layers und Aktivierungsfunktionen dazwischen, sonst nichts
# Input-Vairable hier ist x, das für einen Batch (de facto eine Liste) von Inputs steht
def forward(self, x):
"""
Hier wird ein Dateninput durch das Netz geschleust
"""
# Am Anfang müssen wir (leider) dafür sorgen, dass das Datenformat passt
# Der Standard-Container für Daten in PyTorch ist ein sogenannter "Tensor"
# Dieser speichert Werte an verschiedenen Stellen im KNN, aber gleichzeitig
# auch noch die Gradienten an diesen Stellen, sodass beim Optimieren einfach
# und schnell darauf zugegriffen werden kann
x = torch.tensor(x).float()
# x geht von Input zu hidden Layer 1
x = self.fc_1(x)
# x geht durch eine Sigmoid-Funktion
x = torch.sigmoid(x)
# x geht von hidden Layer 1 zu hidden Layer 2
x = self.fc_2(x)
# x geht durch eine Sigmoid-Funktion
# x = torch.sigmoid(x)
# x geht von hidden Layer 2 zu hidden Layer 3 (für später, falls gewünscht)
# x = self.fc_3(x)
# x geht durch eine Sigmoid-Funktion
x = torch.sigmoid(x)
# x geht ins Output Layer
x = self.fc_4(x)
# Die outputs werden zurückgegeben
return x
# Als nächstes wird definiert, was in einem Trainingsschritt alles passieren soll
# Ein Trainingsschritt bedeutet hier, dass ein Batch von Daten innerhalb einer
# Epoche zum Training verwendet wird
# Der Batch und ein zugehöriger Index sind daher auch Inputs dieser Methode, die
# man verwenden kann (aber nicht muss)
def training_step(self, batch, batch_idx):
# zunächst nehmen wir den Batch in die Inputs und Labels auseinander wie gewohnt
X, y = batch
# Hier kommt der sogenannte "forward pass", d.h. wir rufen "self" auf, was im
# konkreten Fall eines Pytorch-Lightning oder PyTorch Moduls de facto die
# "forward"-Methode aufruft
y_hat = self(X)
# Wir berechnen die Loss-Funktion für diese Vorhersagen im Vergleich zu
# den echten Labels
loss = self.loss(y_hat, y)
# und hier kommt ein Befehl, der die laufende Ausgabe während des Trainings
# und das "Logging", also die Aufzeichnungen der Trainingsfortschritts
# steuert. Logging ist sehr mächtig und kann vielfältig eingesetzt werden.
# Z.B. kann es die Plots ersetzen, die wir hier nachher mit der Hand aus
# unseren Listen erzeugen werden.
# Wir begnügen uns hier aber damit, einfach die laufende Anzeige zu verfolgen.
self.log('train_loss', loss, on_step=False, on_epoch=True, prog_bar=True)
# hier hängen wir den Wert noch an eine unserer Listen an. Dazu müssen wir
# zunächst den Zahlenwert mit "detach" aus dem Tensor holen und danach in
# ein Numpy-Array verwandeln, damit wir es nachher einfach verwenden können.
self.train_loss.append(loss.detach().numpy())
# Zurückgegeben wird hier der Wert des Losses, denn daran orientiert sich
# der Optimierungsprozess, der hier fast komplett "unter der Motorhaube" läuft.
return loss
# Als nächstes wird definiert, was in einem Validierungsschritt alles passieren soll
# Ein Validierungsschritt bedeutet hier, dass ein Batch von Daten innerhalb einer
# Epoche aus dem Validierungsset verwendet wird
# Diese Methode ist analog zu verwenden und zu schreiben wie der Trainingsschritt
def validation_step(self, batch, batch_idx):
X, y = batch
y_hat = self(X)
loss = self.loss(y_hat, y)
# hier hängen wir den Loss-Wert an die entsprechende Liste für die Validierung an
self.val_loss.append(loss.detach().numpy())
self.log('val_loss', loss, on_step=False, on_epoch=True, prog_bar=True)
return loss
# Als nächstes wird definiert, was in einem Testschritt alles passieren soll
# Ein Testschritt bedeutet hier, dass ein Batch von Daten nach dem fertigen
# Training aus dem Testset verwendet wird
# Diese Methode ist wieder analog zu verwenden und zu schreiben wie der Trainingsschritt
def test_step(self, batch, batch_idx):
X, y = batch
y_hat = self(X)
loss = self.loss(y_hat, y)
# hier berechnen wir zusätzlich noch die Accuracy, so wie wir das auch in der
# vergangenen Einheit bereits gemacht haben. Diesmal verwenden wir die Funktion,
# wie Sie von PyTorch bereit gestellt wird
acc = accuracy(y_hat, y)
# Für den Log stellen wir diesmal zwei Variablen in einem Dictionary bereit
metrics = {"test_acc": acc, "test_loss": loss}
# und wir loggen dieses Dictionary
self.log_dict(metrics)
# außerdem wird das Dictionary zurückgegeben
return metrics
# Jetzt kommt die (in PyTorch Lightning sehr kurze) Definition des zu verwendenden
# Optimierungs-Algorithmus
def configure_optimizers(self):
# ein üblicher Algorithmus für Gradient Descent beim Deep Learning ist "Adam"
# Hier wird übrigens die Schrittweite (der oben bereits erwähnte Hyperparameter
# der "learning rate") definiert
# Das erste Argument im Adam-Aufruf sind die zu optimierenden Parameter, hier
# alle Parameter im Netz
optimizer = optim.Adam(self.parameters(), lr=0.001)
# Der eingerichtete Optimierungs-Algorithmus wird zurückgegeben
return optimizer
# Jetzt kommt die (optionale) Einrichtung der Daten nach bestimmten Ideen oder
# Kriterien am Anfang des Trainings.
# Wir nutzen dies, um die Aufteilung in Training, Validation und Test vorzunehmen
# als Argument gibt es hier "stage", das wir aber nicht verwenden
def setup(self, stage):
# Zunächst erzeugen wir eine Instanz unserer Datensatz-Klasse von oben
# Hier befinden sich nun unsere vorher erzeugten Daten, aber in leicht
# zugänglichem Format
all_data = OurData()
# Wir fragen die Länge des Datensatzes ab und schreiben sie raus
data_length = len(all_data)
print("Länge der gesamten Daten: ", data_length)
# Als nächstes berechnen wir die Längen von Validierungs- und Test-Set
# Für die Validierung nehmen wir 20% der Daten
self.validation_number = int(data_length * 0.2)
# Und auch zum Testen nehmen wir 20% der Daten
self.test_number = int(data_length * 0.2)
# Den Rest nehmen wir für das Training
self.training_number = data_length - self.test_number - self.validation_number
# die Ausgabe dieser Zahlen, zur Kontrolle
print("Daten-Partitionierung:", self.training_number, self.validation_number, self.test_number, data_length)
# Und hier wird der Datensatz dann aufgesplittet
train_part, val_part, test_part = random_split(all_data, [self.training_number, self.validation_number, self.test_number])
# Zum Schluss machen wir noch diese Teile des Datensatzes für die Instanz verfügbar
self.train_dataset = train_part
self.val_dataset = val_part
self.test_dataset = test_part
return
# Hier definieren wir noch drei sogenannte "Dataloader". Das hat nur den Zweck, die
# einzelnen Teile des Datensatzes den richtigen Prozessen beim Training, Validierung
# und Testen zuzuordnen. Außerdem wird hier die Batchsize für die verschiedenen
# Prozesse eingestellt (die könnten auch verschieden sein).
# Der Parameter "num_workers" ist etwas problematisch. Ich muss ihn auf dem Mac auf 0
# setzen, dann läuft das Training gut und auch schnell. Auf anderer Hardware kann
# damit eingestellt werden, wie viele Prozesse mit der Datenvorbereitung z.B. für die
# GPU beschäftigt sein sollen.
def train_dataloader(self):
return DataLoader(self.train_dataset, batch_size=self.batch_size, num_workers=0)
def val_dataloader(self):
return DataLoader(self.val_dataset, batch_size=self.batch_size, num_workers=0)
def test_dataloader(self):
return DataLoader(self.test_dataset, batch_size=self.batch_size, num_workers=0)
# So, nun ist es soweit. Alles ist vorbereitet und wir starten die Maschine so wie
# oben bereits angedeutet.
# definiere die Anzahl der maximalen Epochen vorab
epochs_to_use = 300
# Starten des PyTorch Lightning Trainers
# Die maximale Epochen ist auf 100 gesetzt, kann dann aber auch erhöht werden
trainer = pl.Trainer(max_epochs=epochs_to_use)
# Als nächstes erzeugen wir eine Instanz unserer eigenen Netzwerk-Python-Klasse,
# die hier das Machine-Learning-Modell ist
# Die Batch-Size sollte grundsätzlich eher groß gewählt werden. Für unsere 5000 Trainingspunkte
# mit 60-20-20-Aufteilung eignet sich 1000 gut.
# die Anzahl der Neuronen in den hidden Layers setzen wir erst einmal auf 8, später höher
model = OurNetwork(batch_size=1000, hidden_dim=8)
# Dann rufen wir das Training auf, analog zur vergangenen Einheit
trainer.fit(model)
# Und schließlich rufen wir den Test auf, das ist hier analog zum Training gecodet
trainer.test(model)
# und hier folgt der Output von PyTorch Lightning:
GPU available: False, used: False TPU available: False, using: 0 TPU cores | Name | Type | Params | In sizes | Out sizes ------------------------------------------------------------------ 0 | fc_1 | Linear | 24 | [1000, 2] | [1000, 8] 1 | fc_2 | Linear | 72 | [1000, 8] | [1000, 8] 2 | fc_3 | Linear | 72 | ? | ? 3 | fc_4 | Linear | 18 | [1000, 8] | [1000, 2] 4 | loss | CrossEntropyLoss | 0 | ? | ? ------------------------------------------------------------------ 186 Trainable params 0 Non-trainable params 186 Total params 0.001 Total estimated model params size (MB)
Successfully created training data of length: 5000 Länge der gesamten Daten: 5000 Daten-Partitionierung: 3000 1000 1000 5000
-------------------------------------------------------------------------------- DATALOADER:0 TEST RESULTS {'test_acc': 0.7630000114440918, 'test_loss': 0.5091737508773804} --------------------------------------------------------------------------------
[{'test_acc': 0.7630000114440918, 'test_loss': 0.5091737508773804}]
Das ist nun einmal recht gut gelaufen. Auch die Accuracy auf den Testdaten wissen wir bereits. Was ist aber beim Training genau passiert, und wie verhalten sich die Losses bei Training und Validation? Dafür machen wir jetzt noch einen Plot in alter Tradition.
# Erzeuge neue Figure
fig = plt.figure()
# Plotte zunächst die Trainings-Losses für jede Epoche. Dazu müssen wir nur ein
# Bisschen die Epochen von Trainingsteil und Validierungsteil vergleichbar machen.
# Wegen der Aufteilung der Datenpakete im Verhältnis 1:3 von Validation zu Training
# und der Aneinanderreihung der Werte für die Batches (nicht die Epochen) gibt es
# hier den entsprechenden Faktor zu entfernen.
plt.plot(np.arange(epochs_to_use*3)/3, np.array(model.train_loss), label="train")
# Hier der analoge Plot für die Validation-Losses
plt.plot(np.arange(epochs_to_use+1), np.array(model.val_loss), label="validation")
plt.legend(loc="upper right")
# Die Achsen kann man auf logarithmische Ansicht schalten, um mehr Details zu sehen
plt.yscale("log")
plt.xscale("log")
plt.show()
Hier sieht man, dass die Loss-Funktion beim Training etwas weiter nach unten geht als für die Validierung. Das bedeutet, die Labels im Validierungs-Set werden nicht so gut wiedergegeben wie jene im Trainingsset, was zu erwarten ist.
11.7 Übungsaufgabe: Experimentieren mit dem Beispiel-KNN in PyTorch Lightning¶
Nachdem Sie nun bis hierher durchgehalten haben, ist es an der Zeit, dass Sie selbst mit diesem Werkzeug experimentieren. Auch wenn es am Anfang unübersichtlich ist, werden Sie mit der Zeit dahinter kommen, wie die einzelnen Teile funktionieren und noch viel spannendere Netzwerke bauen können, als wir es hier getan haben. Hilfreich sind dabe auf jeden Fall grundsätzlich die Dokumentationen von PyTorch und PyTorch Lightning, aber das ist eher für spätere Versuche.
Für den Anfang bieten sich folgende Versuche an:
- Ändern Sie die Dimension der hidden Layers (also die Anzahl der Neuronen dort) und beobachten Sie, was passiert.
- Schalten Sie das dritte hidden Layer dazu und beobachten Sie, was passiert.
- Nehmen Sie das zweite hidden Layer heraus und beobachten Sie, was passiert.
- Versuchen Sie, die Test-Accuracy möglichst hoch hinaufzubringen.
- Sie können auh den Trainingsdatensatz vergrößern (normalerweise bringen mehr Daten beim Deep Learning Vorteile).
Jedenfalls wünsche ich Ihnen dabei viel Vergnügen!