Im Kapitel „Backpropagation“ haben wir Gradienten von Hand rückwärts durch ein Netz gerechnet. PyTorch tut genau dasselbe – automatisch, für beliebige Architekturen, und mit demselben Muster: Zwischenergebnisse im Vorwärtsdurchlauf speichern, Kettenregel im Rückwärtsdurchlauf. Dieses Kapitel schaut hinter die Kulissen von loss.backward().
Ein Beispiel im Kleinen¶
Ein Blick darauf, wie PyTorch Backpropagation tatsächlich macht. Wir bauen ein sehr kleines „Netzwerk“:
import torch
x = torch.tensor(3.0) # Input (Konstante)
w = torch.tensor(2.0, requires_grad=True) # Gewicht: "Beobachte mich!"
b = torch.tensor(1.0, requires_grad=True) # Bias
z = w * x # z = 6
y = z + b # y = 7
L = y ** 2 # Loss = 49Der Berechnungsgraph (ein DAG, directed acyclic graph) entsteht erst beim Ausführen: Jeder Tensor, der aus einer Operation mit einem requires_grad-Tensor entstanden ist, trägt eine grad_fn, und man kann den Graphen von hinten nach vorn ablaufen:
print(L.grad_fn) # <PowBackward0>
print(L.grad_fn.next_functions[0][0]) # <AddBackward0>
print(L.grad_fn.next_functions[0][0].next_functions)
# ((<MulBackward0>, 0), (<AccumulateGrad>, 0)) -- z, und das Blatt bDie grad_fn weiß, wie man die Operation ableitet, und hält Referenzen auf die saved tensors, die gespeicherten Zwischenergebnisse – genau die aus dem Kapitel „Backpropagation“. AccumulateGrad sind die Blätter des Graphen, die Parameter und ; die topologische Sortierung ist implizit.
L.backward() ist der rekursive Aufruf der Kettenregel, initial mit :
L.backward()
print(w.grad, b.grad) # tensor(42.), tensor(14.)Danach steht das Ergebnis in w.grad und b.grad. Ein Schritt Gradientenabstieg von Hand, das ist alles, was optimizer.step() tut:
lr = 0.01
with torch.no_grad(): # sonst würde der Update selbst Teil des Graphen
w -= lr * w.grad # 2.0 - 0.01 * 42 = 1.58
b -= lr * b.grad # 1.0 - 0.01 * 14 = 0.86
w.grad.zero_() # sonst wird beim nächsten backward() akkumuliert
b.grad.zero_()Der Optimizer-Schritt muss unter torch.no_grad() passieren, damit der Update selbst nicht Teil des Graphen wird, und die Gradienten müssen danach mit zero_() zurückgesetzt werden – sonst akkumuliert PyTorch sie beim nächsten backward(). Das ist der Grund für optimizer.zero_grad() in jeder Trainingsschleife.
Freezing¶
Setzt man w.requires_grad = False, so hat z = w * x keine grad_fn mehr, der Graph ist an dieser Stelle abgeschnitten:
w.requires_grad = False
z = w * x
print(z.grad_fn) # None -- Blatt abgeschnitten
print((z + b).grad_fn) # <AddBackward0> -- b wird weiter beobachtetEin Optimizer verändert nicht mehr, aber schon. So friert man beim Transfer Learning Schichten eines vortrainierten Modells ein – und spart dabei Speicher, weil für eingefrorene Parameter keine Gradienten gehalten werden.
Geteilte Gewichte: Backprop durch eine Faltung¶
Wie funktioniert Backprop, wenn Gewichte geteilt werden wie bei einem Faltungslayer? Der Kernel [0.5] wird über den Input [1, 2, 3] geschoben, , und der Loss ist :
x = torch.tensor([1.0, 2.0, 3.0])
k = torch.tensor([0.5], requires_grad=True)
o1, o2, o3 = x[0] * k, x[1] * k, x[2] * k # von Hand, wie nn.Conv1d
loss = o1 + o2 + o3
loss.backward()
print(k.grad) # tensor([6.])Der Gradient nach ist die Summe der Beiträge aller Positionen, : Ein geteilter Parameter bekommt die Gradienten aller seiner Verwendungen aufsummiert – im Graphen führen mehrere Kanten zu demselben Blatt.
Übung¶
Ein Modell hat drei Layer. Vor dem Training wird für Layer 1 und 2
requires_grad = Falsegesetzt. Welche Tensoren bekommen im Backward Pass einen.grad, welche nicht – und wo wird trotzdem noch gerechnet? Tipp: Zeichnen Sie den Graphen und verfolgen Sie die Pfeile rückwärts, von der Loss-Funktion zu den Blättern. Fragen Sie sich bei jedem Layer: Muss der Gradient hier überhaupt noch hindurch, damit weiter vorne etwas ankommt?Ein Gewicht wird an drei Stellen im Graphen benutzt (geteilte Gewichte, wie bei einer Faltung). Wie viele Beiträge hat , und wie werden sie verrechnet?
Antwortprobe: Bei (1) spart das Einfrieren Speicher, aber nicht jede Rechnung.