Aufgrund der genannten Eigenschaften ist PyTorch vor allem bei Deep-Learning-Forschern und bei Entwicklern im Bereich Natural Language Processing (NLP) sehr beliebt. Auch im Bereich Integration und Deployment wurden in der letzten Version, dem ersten offiziellen Release 1.0, wesentliche Neuerungen eingeführt.
Tensoren
Die elementare Datenstruktur zur Repräsentation und Verarbeitung von Daten in PyTorch ist torch.Tensor. Der mathematische Begriff Tensor steht für eine Generalisierung von Vektoren und Matrizen. In PyTorch werden Tensoren in Form von multidimensionalen Arrays implementiert. Ein Vektor ist dabei nichts anderes als ein eindimensionaler Tensor (oder ein Tensor mit Rang 1), deren Elemente Zahlen eines bestimmten Datentyps (z. B. torch.float64 oder torch.int32) sein können. Eine Matrix ist somit ein zweidimensionaler Tensor (Rang 2) und ein Skalar ein nulldimensionaler Tensor (Rang 0). Tensoren noch höherer Dimensionen besitzen keine speziellen Namen mehr (Abb. 1).
Das Interface für PyTorch-Tensoren lehnt sich stark an das Design von multidimensionalen Arrays in NumPy an. Genauso wie NumPy stellt PyTorch vordefinierte Methoden bereit, mit denen man Tensoren manipulieren und Operationen der linearen Algebra durchführen kann. Einige Beispiele sind in Listing 1 dargestellt.
# Generierung eines eindimensionalen Tensors mit # 8 (uninitialisierten) Elementen (float32) x = torch.Tensor(8) x.double() # Konvertierung nach float64 Tensor x.int() # Konvertierung nach int32 Datentyp # 2D-Long-Tensor vorinitialisiert mit Nullen x = torch.zeros([2, 2]) # 2D-Long-Tensor vorinitialisiert mit Einsen # und anschließende Konvertierung nach int64 y = torch.ones([2, 3]).long() # Zusammenfügen zweier Tensoren entlang Dimension 1 x = torch.cat([x, y], 1) x.sum() # Summe über alle Elemente x.mean() # Durchschnitt über alle Elemente # Matrixmultiplikation x.mm(y) # Transponieren x.t() # Inneres Produkt zweier Tensoren torch.dot(x, y) # Berechnet Eigenwerte und -vektoren torch.eig(x) # Gibt Tensor mit dem Sinus der Elemente zurück torch.sin(x)
Die Anwendung optimierter Bibliotheken wie BLAS, LAPACK und MKL erlaubt eine höchst performante Ausführung von Tensoroperationen auf der CPU (vor allem mit Intel-Prozessoren). Zusätzlich unterstützt PyTorch (im Gegensatz zu NumPy) auch die Ausführung der Operationen auf NVIDIA-Grafikkarten mit Hilfe des CUDA-Toolkits und der CuDNN-Bibliothek. Listing 2 zeigt an einem Beispiel, wie man Tensorobjekte auf den Speicher der Grafikkarte verschiebt, um dort optimierte Tensoroperationen durchzuführen.
# 1D Tensoren x = torch.ones(1) y = torch.zeros(1) # Tensoren auf den GPU-Speicher verschieben x = x.cuda() y = y.cuda() # oder: device = torch.device("cuda") x = x.to(device) y = y.to(device) # Die Additionsoperation wird nun auf der GPU durchgeführt x + y # wie torch.add(x, y) # Zurückkopieren auf die CPU x = x.cpu() y = y.cpu()
Da NumPy-Arrays quasi als Standarddatenstrukturen in der Python-Data-Science-Community gelten, ist in der Praxis ein häufiges Konvertieren von PyTorch nach NumPy und zurück nötig. Diese Konvertierungen können unkompliziert und effizient durchgeführt werden (Listing 3), da dabei der gleiche Speicherbereich geteilt wird, sodass kein Kopieren von Speicherinhalten durchgeführt werden muss.
# Konvertierung nach NumPy x = x.numpy() # Konvertierung zurück als PyTorch-Tensor y = torch.from_numpy(x) # y zeigt jetzt auf den gleichen Speicherbereich wie x # eine Änderung von y ändert gleichzeitig auch x
Netzwerkmodule
Die Bibliothek torch.nn enthält viele Tools und vordefinierte Module zur Generierung neuronaler Netzwerkarchitekturen. In der Praxis definiert man seine eigenen Netzwerke durch die Ableitung der abstrakten Klasse torch.nn.Module. In Listing 4 ist die Implementierung eines einfachen Feed-Forward-Netzwerks mit einem Hidden Layer und einer Tanh-Aktivierung aufgeführt.
import torch import torch.nn as nn class Net(nn.Module): def __init__(self, input_dim, hidden_dim, output_dim): super(Net, self).__init__() # Hier generierst du Instanzen aller Submodule des Netzwerks self.fc1 = nn.Linear(input_dim, hidden_dim) self.act1 = nn.Tanh() self.fc2 = nn.Linear(hidden_dim, output_dim) def forward(self, x): # Hier definierst du die Vorwärtssequenz # torch.autograd generiert dynamisch einen Graphen bei jedem Durchlauf x = self.fc1(x) x = self.act1(x) x = self.fc2(x) return x
Dabei wird eine Netzwerkklasse von der abstrakten Klasse nn.Module abgeleitet. Die Methoden __init__() und forward() müssen dabei definiert werden. In __init__() sollten alle benötigten Elemente, aus denen das gesamte Netzwerk zusammengebaut ist, instanziiert und initiiert werden. In unserem Falle generieren wir drei Elemente:
- fc1 – mit nn.Linear(input_dim, hidden_dim) wird ein Fully Connected Layer mit einer Eingabedimension von input_dim und einer Ausgabedimension von hidden_dim erzeugt
- act1 – eine Tanh-Aktivierungsfunktion
- fc2 – ein weiterer Fully Connected Layer mit einer Eingabedimension von hidden_dim und einer Ausgabedimension von output_dim.
Die Reihenfolge in __init()__ ist im Grunde egal, aber aus stilistischen Gründen sollte man sie möglichst in der Reihenfolge generieren, in der sie in der Methode forward() aufgerufen werden. Entscheidend für die Prozessierung ist die Reihenfolge in der forward()-Methode, in der man die Sequenzen des Vorwärtsdurchlaufs festlegt. An dieser Stelle kann man sogar beliebige bedingte Abfragen und Verzweigungen einbauen, da bei jedem Lauf ein Berechnungsgraph dynamisch generiert wird (Listing 5). Das ist nützlich, wenn man z. B. mit variierenden Batchgrößen arbeiten oder mit komplexen Verzweigungen experimentieren möchte. Insbesondere die Behandlung von Sequenzen unterschiedlicher Länge als Eingabe, wie es häufig bei vielen NLP-Problemen vorkommt, ist mit dynamischen Graphen wesentlich unkomplizierter zu realisieren als mit statischen.
class Net(nn.Module): ... def forward(self, x, a, b): x = self.fc1(x) # Bedingte Anwendung der Aktivierungsfunktion if a > b: x = self.act1(x) x = self.fc2(x) return x
„autograd“ und dynamische Graphen
PyTorch benutzt das Paket torch.autograd, um bei jedem Vorwärtslauf dynamisch einen gerichteten azyklischen Graphen (DAG) zu generieren. Im Gegensatz dazu wird bei einer statischen Generierung der Graph am Anfang einmal komplett konstruiert und danach nicht mehr geändert. Der statische Graph wird bei jeder Iteration mit den neuen Daten gefüllt und ausgeführt. Dynamische Graphen haben einige Vorteile bzgl. der Flexibilität, wie schon im vorigen Abschnitt beschrieben wurde. Die Nachteile liegen im Bereich der Möglichkeiten der Optimierung, des verteilten (parallelen) Trainierens und des Deployments der Modelle.
Durch die Definition des Vorwärtspfades generiert torch.autograd einen Graphen, dessen Knoten die Tensoren und dessen Kanten die elementaren Tensoroperationen repräsentieren. Mit Hilfe dieser Informationen können die Gradienten aller Tensoren automatisch zur Laufzeit ermittelt und somit Backpropagation effizient durchgeführt werden. Ein Beispielgraph ist in Abbildung 2 dargestellt.
Debugger
Der größte Vorteil der Implementierung dynamischer Graphen gegenüber statischen Graphen liegt in der Möglichkeit des Debuggings. Innerhalb der forward()-Methode kann man beliebige Printouts durchführen oder Breakpoints setzen, die z. B. mit Hilfe des Standarddebuggers pdb analysiert werden können. Bei statischen Graphen ist das nicht ohne Weiteres möglich, da man zur Laufzeit keinen direkten Zugriff auf die Objekte des Netzwerks besitzt.
Training
Das Paket torchvision enthält viele nützliche Werkzeuge, vortrainierte Modelle und Datensätze für den Bereich Bildverarbeitung. In Listing 6 wird der Datensatz FashionMNIST geladen. Er besteht aus einem Trainings- und Validierungsdatensatz von 60 000 bzw. 10 000 Icons aus dem Modebereich.
transform = transforms.Compose([transforms.ToTensor()]) # weitere Beispiele für Transformationen: # transforms.RandomSizedCrop() # transforms.RandomHorizontalFlip() # transforms.Normalize() # Download und Laden des Trainingsdatensatzes (60k) trainset = datasets.FashionMNIST('./FashionMNIST/', download=True, train=True, transform=transform) # Objekt der Klasse torch.utils.data.Dataset trainloader = DataLoader(trainset, batch_size=batch_size, shuffle=True, num_workers=4) # Download und Laden des Validationsdatensatzes (10k) validset = datasets.FashionMNIST('./FashionMNIST/', download=True, train=False, transform=transform) # Objekt der Klasse torch.utils.data.Dataset validloader = DataLoader(validset, batch_size=batch_size, shuffle=True, num_workers=4)
Die Icons sind Graustufenbilder aus 28×28 Pixeln, die in zehn Klassen eingeteilt sind (0-9): 0. T-Shirt, 1. Trouser, 2. Pullover, 3. Dress, 4. Coat, 5. Sandal, 6. Shirt, 7. Sneaker, 8. Bag, 9. Ankle Boot (Abb. 3). Die Klasse Dataset repräsentiert einen Datensatz, den man beliebig partitionieren und auf den man verschiedene Transformationen anwenden kann. In diesem Beispiel werden die NumPy-Arrays in Torch-Tensoren konvertiert. Daneben werden aber noch etliche andere Transformationen zur Augmentierung und Normalisierung der Daten angeboten (z. B. Ausschnitte, Rotationen, Spiegelungen etc.). DataLoader ist eine Iterator-Klasse, die einzelne Batches des Datensatzes generiert und in den Speicher lädt, sodass man große Datensätze nicht vollständig laden muss. Optional kann man auswählen, ob man mehrere Threads starten möchte (num_workers) oder ob der Datensatz vor jeder Epoche neu gemischt werden soll (shuffle).
In Listing 7 generieren wir zunächst eine Instanz unseres Modells und verschieben den kompletten Graphen auf die GPU. PyTorch bietet verschiedene Loss-Funktionen und Optimierungsalgorithmen an. Für ein Multi-Label-Klassifizierungsproblem können beispielsweise als Loss-Funktion CrossEntropyLoss() und Stochastic Gradient Descent (SGD) als Optimierungsalgorithmus gewählt werden. An die Methode SGD() werden die Parameter des Netzwerks übergeben, die optimiert werden sollen. Ein optionaler Parameter ist die Learning Rate (lr).
import torch.optim as optim # Benutze die GPU falls vorhanden device = torch.device("cuda" if torch.cuda.is_available() else "cpu") # Definiere Modell input_dim = 784 hidden_dim = 100 output_dim = 10 model = Net(input_dim, hidden_dim, output_dim) model.to(device) # verschiebe alle Elemente des Graphen auf das aktuelle Device # Definiere Optimizer-Algorithmus und verknüpfe mit Modellparametern optimizer = optim.SGD(model.parameters(), lr=0.01) # Definiere Loss-Funktion: CrossEntropy für Klassifikation loss_function = nn.CrossEntropyLoss()
Die Funktion train() in Listing 8 führt eine Trainingsiteration durch. Am Anfang werden alle Gradienten des Netzwerkgraphen zurückgesetzt (zero_grad()). Danach wird ein Vorwärtslauf durch den Graphen durchgeführt. Der Loss-Wert wird durch einen Vergleich der Netzwerkausgabe und des Label-Tensors ermittelt. Die Gradienten werden mit backward() berechnet und mit optimizer.step() schließlich die Gewichte des Netzwerks mittels Backpropagation aktualisiert. Eine Variante der Trainingsiteration ist die Validationsiteration valid(), dabei werden alle Backpropagation-Arbeitsschritte ausgelassen.
# Training eines Batch def train(model, images, label, train=True): if train: model.zero_grad() # Zurücksetzen der Gradienten x_out = model(images) loss = loss_function(x_out, label) # Ermittle Loss-Wert if train: loss.backward() # Berechne alle Gradienten optimizer.step() # Aktualisiere die Gewichte return loss # Validierung: Nur Vorwärtslauf ohne Backpropagation def valid(model, images, label): return train(model, images, label, train=False)
Die vollständige Iteration über mehrere Epochen ist in Listing 9 aufgeführt. Für die Anwendung des Feed-Forward-Modells Net() müssen die Icon-Tensoren mit den Dimensionen (batch_size, 1, 28, 28) nach (batch_size, 784) transformiert werden. Der Aufruf von train_loop() sollte also mit dem Argument ‚flatten‚ erfolgen:
train_loop(model, trainloader, validloader, 10, 200, 'flatten')
import numpy as np def train_loop(model, trainloader, validloader=None, num_epochs = 20, print_every = 200, input_mode='flatten', save_checkpoints=False): for epoch in range(num_epochs): # Trainingsschleife train_losses = [] for i, (images, labels) in enumerate(trainloader): images = images.to(device) if input_mode == 'flatten': images = images.view(images.size(0), -1) # Flattening des Images elif input_mode == 'sequence': images = images.view(images.size(0), 28, 28) # Sequence aus 28 Elementen mit 28 Features labels = labels.to(device) loss = train(model, images, labels) train_losses.append(loss.item()) if (i+1) % print_every == 0: print('Training', epoch+1, i+1, loss.item()) if validloader is None: continue # Validationsschleife val_losses = [] for i, (images, labels) in enumerate(validloader): images = images.to(device) if input_mode == 'flatten': images = images.view(images.size(0), -1) # Flattening des Images elif input_mode == 'sequence': images = images.view(images.size(0), 28, 28) # Sequence aus 28 Elementen mit 28 Features labels = labels.to(device) loss = valid(model, images, labels) val_losses.append(loss.item()) if (i+1) % print_every == 0: print('Validation', epoch+1, i+1, loss.item()) print('--- Epoch, Train-Loss, Valid-Loss:', epoch, np.mean(train_losses), np.mean(val_losses)) if save_checkpoints: model_filename = 'checkpoint_ep'+str(epoch+1)+'.pth' torch.save({ 'model_state_dict': model.state_dict(), 'optimizer_state_dict': optimizer.state_dict(), }, model_filename)
Speichern und Laden trainierter Gewichte
Um die Modelle später zur Inferenz in einer Anwendung nutzen zu können, ist es möglich, die trainierten Gewichte in Form serialisierter Python-Dictionary-Objekte zu speichern. Dafür wird das Python-Paket pickle benutzt. Möchte man das Modell später weitertrainieren, sollte man auch den letzten Status des Optimizers speichern. In Listing 9 werden Modellgewichte und aktueller Status des Optimizers nach jeder Epoche gespeichert. Listing 0 zeigt, wie eine dieser pickle-Dateien geladen werden kann.
model = Net(input_dim, hidden_dim, output_dim) checkpoint = torch.load('checkpoint_ep2.pth') model.load_state_dict(checkpoint['model_state_dict']) optimizer = torch.optim.SGD(model.parameters(), lr=0.01) optimizer.load_state_dict(checkpoint['optimizer_state_dict'])
Netzwerkmodule
PyTorch bietet noch viele weitere vordefinierte Module zum Konstruieren von Convolutional Neural Networks (CNN), Recurrent Neural Networks (RNN) oder noch komplexeren Architekturen wie Encoder-Decoder-Systemen. Das Net()-Modell könnte z. B. mit einem Dropout Layer erweitert werden (Listing 11).
class Net(nn.Module): def __init__(self, input_dim, hidden_dim, output_dim): super(Net, self).__init__() self.fc1 = nn.Linear(input_dim, hidden_dim) self.dropout = nn.Dropout(0.5) # Dropout Layer mit Wahrscheinlichkeit 50 Prozent self.act1 = nn.Tanh() self.fc2 = nn.Linear(hidden_dim, output_dim) def forward(self, x): x = self.fc1(x) x = self.dropout(x) # Dropout nach dem ersten FC Layer x = self.act1(x) x = self.fc2(x) return x
Listing 12 zeigt ein Beispiel für ein CNN aus zwei Convolutional Layers mit Batch Normalization, jeweils einer ReLU-Aktivierung und einem Max Pooling Layer. Der Trainingsaufruf könnte dann so aussehen:
model = CNN(10).to(device) optimizer = optim.SGD(model.parameters(), lr=0.01) train_loop(model, trainloader, validloader, 10, 200, None)
class CNN(nn.Module): def __init__(self, num_classes=10): super(CNN, self).__init__() self.layer1 = nn.Sequential( nn.Conv2d(1, 16, kernel_size=5, padding=2), nn.ReLU(), nn.MaxPool2d(2) ) self.layer2 = nn.Sequential( nn.Conv2d(16, 32, kernel_size=5, padding=2), nn.ReLU(), nn.MaxPool2d(2)) self.fc = nn.Linear(7*7*32, 10) def forward(self, x): out = self.layer1(x) out = self.layer2(out) out = out.view(out.size(0), -1) # Flattening für FC-Input out = self.fc(out) return out
Ein Beispiel für ein LSTM-Netzwerk, das mit dem Adam Optimizer optimiert wird, wird in Listing 13 gezeigt. Dabei werden die Pixel der Bilder des FashionMNIST-Datensatzes als Sequenzen aus 28 Elementen mit jeweils 28 Features interpretiert und entsprechend vorprozessiert.
# Recurrent Neural Network class RNN(nn.Module): def __init__(self, input_size, hidden_size, num_layers, num_classes): super(RNN, self).__init__() self.hidden_size = hidden_size self.num_layers = num_layers self.lstm = nn.LSTM(input_size, hidden_size, num_layers, batch_first=True) self.fc = nn.Linear(hidden_size, num_classes) def forward(self, x): # Initialisiere Hidden und Cell States h0 = torch.zeros(self.num_layers, x.size(0), self.hidden_size).to(device) c0 = torch.zeros(self.num_layers, x.size(0), self.hidden_size).to(device) out, _ = self.lstm(x, (h0, c0)) out = self.fc(out[:, -1, :]) # letzter Hidden State return out sequence_length = 28 input_size = 28 hidden_size = 128 num_layers = 1 model = LSTM(input_size, hidden_size, num_layers, output_dim).to(device) criterion = nn.CrossEntropyLoss() optimizer = torch.optim.Adam(model.parameters(), lr=0.01) train_loop(model, trainloader, validloader, 10, 200, 'sequence')
Das Paket torchvision ermöglicht auch das Laden bekannter Architekturen oder sogar ganzer vortrainierter Modelle, die man als Grundlage für die eigenen Anwendungen oder zum Transfer Learning benutzen kann. Ein vortrainiertes VGG-Modell mit 19 Layers kann beispielsweise folgendermaßen geladen werden:
from torchvision import models vgg = models.vgg19(pretrained=True)
Deployment
Die Integration von PyTorch-Modellen in Anwendungen war bisher immer eine Herausforderung, da die Möglichkeiten, die trainierten Modelle in Produktivsystemen einzusetzen, relativ eingeschränkt waren. Eine häufig benutzte Methode ist die Entwicklung eines REST Services, z. B. mit flask. Dieser REST Service kann lokal oder innerhalb eines Docker Images in der Cloud laufen. Die drei großen Anbieter von Cloudservices (AWS, GCE, Azure) bieten inzwischen aber auch vordefinierte Konfigurationen mit PyTorch an.
Eine Alternative ist die Konvertierung in das ONNX-Format. ONNX (Open Neural Network Exchange Format) ist ein offenes Format zum Austausch von Neural-Network-Modellen, das z. B. auch von MxNet und Caffe unterstützt wird. Das sind Machine Learning Frameworks, die von Amazon und Facebook produktiv eingesetzt werden. Listing 14 zeigt ein Beispiel, wie man ein trainiertes Modell im ONNX-Format exportiert.
model = CNN(output_dim) # Beliebiger Eingabetensor für das Tracing dummy_input = torch.randn(1, 1, 28, 28) # Das Konvertieren nach ONNX erfolgt über Tracing einer Dummyeingabe torch.onnx.export(model, dummy_input, "onnx_model_name.onnx")
TorchScript und C++
Seit der Version 1.0 bietet PyTorch auch die Möglichkeit, Modelle im LLVM-IR-Format zu speichern. Solche können völlig unabhängig von Python ausgeführt werden. Das Werkzeug dafür ist TorchScript, das einen eigenen JIT-Compiler und spezielle Optimierungen (statische Datentypen, optimierte Implementierung der Tensoroperationen) implementiert.
# Beliebiger Eingabetensor für das Tracing dummy_input = torch.randn(1, 1, 28, 28) traced_model = torch.jit.trace(model, dummy_input) traced_model.save('jit_traced_model.pth')
Das TorchScript-Format kann man auf zwei Arten erzeugen. Zum einen über das Tracing eines existierenden PyTorch-Modells (Listing 15) oder durch die direkte Implementierung als Script Module (Listing 16). Im Script-Modus wird ein optimierter statischer Graph generiert. Dieser bietet nicht nur die angesprochenen Vorteile für das Deployment, sondern könnte z. B. auch für das verteilte Trainieren eingesetzt werden.
from torch.jit import trace class Net_script(torch.jit.ScriptModule): def __init__(self, input_dim, hidden_dim, output_dim): super(Net_script, self).__init__() self.fc1 = trace(nn.Linear(input_dim, hidden_dim), torch.randn(1, 784)) self.fc2 = trace(nn.Linear(hidden_dim, output_dim), torch.randn(1, 100)) @torch.jit.script_method def forward(self, x): x = self.fc1(x) x = torch.tanh(x) x = self.fc2(x) return x model = Net_script(input_dim, hidden_dim, output_dim) model.save('jit_model.pth')
Das TorchScript-Modell kann nun mit Hilfe der C++-Frontend-Library (LibTorch) in jede C++-Anwendung eingebunden werden. Das ermöglicht die performante Ausführung der Inferenz, völlig unabhängig von Python in vielen verschiedenen Produktionsumgebungen wie z. B. auf mobilen Geräten.
Fazit
Mit PyTorch kann man sehr effizient und elegant sowohl einfache als auch sehr komplexe Neuronale Netzwerke entwickeln und trainieren. Durch die Implementierung dynamischer Graphen ist das Experimentieren mit sehr flexiblen Architekturen und der Einsatz von Standarddebuggingtools kein Problem. Die nahtlose Anbindung an Python ermöglicht eine sehr schnelle Entwicklung von Prototypen. Diese Merkmale machen PyTorch zurzeit zum beliebtesten Framework für Forscher und experimentierfreudige Entwickler. Die neueste Version bietet auch die Möglichkeit, PyTorch-Modelle in C++-Anwendungen einzubinden und somit eine bessere Integration in Produktivsysteme zu erreichen. Das ist ein erheblicher Fortschritt zu den früheren Versionen. In dieser Kategorie haben aber andere Frameworks, darunter vor allem TensorFlow, noch immer einen deutlichen Vorsprung. Mit TF Extended (TFX), TF Serving und TF Lite bietet das Framework von Google wesentlich anwendungsfreundlichere und robustere Werkzeuge zur Erzeugung von produktionsreifen Modellen. Es wird spannend, welche neuen Entwicklung auf diesem Gebiet wir von PyTorch noch zu sehen bekommen werden.
Entwickler Magazin
Dieser Artikel ist im Entwickler Magazin erschienen.
Natürlich können Sie das Entwickler Magazin über den entwickler.kiosk auch digital im Browser oder auf Ihren Android- und iOS-Devices lesen. In unserem Shop ist das Entwickler Magazin ferner im Abonnement oder als Einzelheft erhältlich.
Super hilfreich und alle wichtigen Features erklärt!