Wstęp
Wsteczna propagacja błędu (backpropagation) to algorytm, który umożliwił praktyczne trenowanie głębokich sieci neuronowych. Działa na prostej zasadzie: oblicza pochodne funkcji straty względem wag sieci, wskazując kierunek, w którym należy je zmodyfikować, aby zmniejszyć błąd.
Warto wiedzieć, że backpropagation to uniwersalny algorytm optymalizacji – działa dla dowolnej funkcji złożonej z wielu operacji, nie tylko sieci neuronowych. Na przykład w transformerach tym samym mechanizmem trenowane są zarówno wagi sieci, jak i embeddingi tokenów, które są po prostu tablicą parametrów, a nie warstwą neuronową.
Dobrym sposobem na zrozumienie backpropagation jest prześledzenie konkretnego przykładu z kartką i długopisem. W tym wpisie przeprowadzimy kompletne obliczenia dla prostej funkcji.
Gradient, pochodna, pochodna cząstkowa – wyjaśnienie terminów
Zanim przejdziemy do obliczeń, jeszcze szybka dygresja matematyczna:
Pochodna – dotyczy funkcji jednej zmiennej: \(f(x)\). Mówi, jak szybko funkcja rośnie wraz ze zmianą \(x\). Oznaczenie: \(\frac{df}{dx}\) lub \(f'(x)\).
Pochodna cząstkowa – dotyczy funkcji wielu zmiennych: \(f(x, y, z)\). Mówi, jak funkcja zmienia się względem jednej zmiennej, przy założeniu, że pozostałe są stałe. Oznaczenie: \(\frac{\partial f}{\partial x}\).
Gradient – to wektor wszystkich pochodnych cząstkowych. Dla funkcji \(f(x, y, z)\) gradient to:
Gradient wskazuje kierunek najszybszego wzrostu funkcji.
W tym wpisie używamy terminu "gradient" w kontekście całego wektora gradientów (np. "obliczamy gradienty parametrów"), a "pochodna cząstkowa" dla poszczególnych składowych (np. \(\frac{\partial L}{\partial a}\)).
Przykład obliczeniowy
Rozważmy funkcję straty (loss) zależną od trzech parametrów:
Dane wejściowe:
- \(a = 2\)
- \(b = -3\)
- \(c = 10\)
Naszym celem jest obliczenie gradientów \(\frac{\partial L}{\partial a}\), \(\frac{\partial L}{\partial b}\), \(\frac{\partial L}{\partial c}\). Powiedzą nam one, jak zmiana każdego parametru wpływa na wartość funkcji straty.
Graf obliczeniowy
Rozbijmy funkcję na elementarne operacje:
a ──┐
├──[×]── d ──┐
b ──┘ ├──[+]── e ──[^2]── L
c ──┘
Gdzie:
- \(d = a \cdot b\)
- \(e = d + c\)
- \(L = e^2\)
Kluczowe jest zdekomponowanie całej funkcji na atomowe operacje, dzięki temu policzenie pochodnych dla każdej z nich będzie trywialne.
Krok 1: Forward Pass
Obliczamy wartości "do przodu", od wejść do wyjścia:
Zapamiętujemy wszystkie wartości pośrednie, w backward pass będą potrzebne do obliczenia gradientów. Bez nich musielibyśmy przeliczać je od nowa, co byłoby nieefektywne.
| Zmienna | Wartość |
|---|---|
| \(a\) | 2 |
| \(b\) | -3 |
| \(c\) | 10 |
| \(d\) | -6 |
| \(e\) | 4 |
| \(L\) | 16 |
Krok 2: Backward Pass
Teraz propagujemy gradienty "wstecz", od wyjścia do wejść. Używamy reguły łańcuchowej:
gdzie \(y\) jest zmienną pośrednią między \(L\) a \(x\).
W Wikipedii można znaleźć bardzo intuicyjne wyjaśnienie tej reguły, które podaje George F. Simmons: "Jeśli samochód jedzie dwa razy szybciej niż rower, a rower cztery razy szybciej niż idący człowiek, to samochód jedzie 2 × 4 = 8 razy szybciej niż człowiek."
2.1 Gradient wyjściowy
Gradient funkcji względem siebie samej wynosi zawsze 1. To nasz punkt startowy.
2.2 Gradient względem \(e\)
2.3 Gradienty względem \(d\) i \(c\)
Pochodna sumy po każdym składniku wynosi 1:
Stosując regułę łańcuchową:
2.4 Gradienty względem \(a\) i \(b\)
Pochodna iloczynu:
Stosując regułę łańcuchową:
Podsumowanie gradientów
| Zmienna | Gradient |
|---|---|
| \(a\) | \(-24\) |
| \(b\) | \(16\) |
| \(c\) | \(8\) |
Interpretacja gradientów
Co nam mówią te liczby?
- \(\frac{\partial L}{\partial a} = -24\) — zwiększenie \(a\) o mały \(\Delta\) zmniejszy \(L\) o około \(24\Delta\)
- \(\frac{\partial L}{\partial b} = 16\) — zwiększenie \(b\) o mały \(\Delta\) zwiększy \(L\) o około \(16\Delta\)
- \(\frac{\partial L}{\partial c} = 8\) — zwiększenie \(c\) o mały \(\Delta\) zwiększy \(L\) o około \(8\Delta\)
Gradient wskazuje kierunek najszybszego wzrostu funkcji. Jeśli chcemy minimalizować \(L\), musimy iść w kierunku przeciwnym do gradientu.
Krok 3: Gradient Descent
Mając gradienty, możemy zaktualizować parametry aby zmniejszyć wartość funkcji straty.
Reguła aktualizacji:
gdzie \(\eta\) to learning rate (współczynnik uczenia). Ustalamy \(\eta = 0.01\).
Obliczenia
Krok 4: Weryfikacja
Sprawdźmy, czy loss rzeczywiście zmalał:
Loss spadł z 16 do 8.07 ✓
Powtarzając ten proces (forward → backward → update) wielokrotnie, loss będzie dalej maleć, aż osiągnie minimum.
Parę słów na koniec
Dlaczego odejmujemy gradient?
Gradient wskazuje kierunek najszybszego wzrostu funkcji. My chcemy ją minimalizować, więc idziemy w przeciwnym kierunku, stąd minus w regule aktualizacji.
Intuicja: jeśli stoisz na zboczu góry i chcesz zejść w dół, idziesz w kierunku przeciwnym do najstromszego wznoszenia.
Rola learning rate
Learning rate \(\eta\) kontroluje wielkość kroku:
- Za duży \(\eta\) — możemy "przeskoczyć" minimum i oscylować lub divergować
- Za mały \(\eta\) — uczenie będzie bardzo powolne
- W sam raz — stabilna konwergencja do minimum
W praktyce dobór learning rate to jeden z kluczowych hiperparametrów. Nowoczesne optymalizatory (Adam, AdaGrad) adaptują go automatycznie dla każdego parametru.
Od przykładu do sieci neuronowej
W prawdziwej sieci neuronowej mamy:
- Tysiące/miliony parametrów (wagi \(w\) i biasy \(b\))
- Wielowarstwowy graf obliczeniowy
- Funkcje aktywacji (ReLU, tanh, sigmoid)
- Operacje macierzowe zamiast skalarnych
Ale mechanizm jest identyczny:
1. Forward pass — oblicz wyjście sieci i loss
2. Backward pass — propaguj gradienty od loss do wszystkich wag
3. Update — zaktualizuj wagi w kierunku przeciwnym do gradientu
4. Repeat — powtarzaj aż loss będzie wystarczająco mały
Implementacja w PyTorch
Na sam koniec jeszcze jedna ciekawostka.
PyTorch (i inne frameworki deep learningowe) automatycznie obliczają gradienty za nas. Zobaczmy, jak wygląda nasz przykład w kodzie:
import torch
# Definiujemy parametry jako tensory z włączonym śledzeniem gradientów
a = torch.tensor([2.0], requires_grad=True)
b = torch.tensor([-3.0], requires_grad=True)
c = torch.tensor([10.0], requires_grad=True)
# Forward pass - PyTorch buduje graf obliczeniowy automatycznie
d = a * b
e = d + c
L = e ** 2
print(f"Loss: {L.item()}") # 16.0
# Backward pass - jeden wywołanie oblicza wszystkie gradienty
L.backward()
# Odczytujemy gradienty
print('---')
print(f'∂L/∂a = {a.grad.item()}') # -24.0
print(f'∂L/∂b = {b.grad.item()}') # 16.0
print(f'∂L/∂c = {c.grad.item()}') # 8.0
Wynik:
Loss: 16.0
---
∂L/∂a = -24.0
∂L/∂b = 16.0
∂L/∂c = 8.0
Dokładnie te same wartości, które obliczyliśmy ręcznie! PyTorch wykonał za nas całą pracę: zbudował graf obliczeniowy, zapamiętał wartości pośrednie i zastosował regułę łańcuchową.
Kluczowe elementy:
- requires_grad=True – włącza śledzenie operacji dla danego tensora
- L.backward() – uruchamia backpropagation od zmiennej L
- .grad – zawiera obliczony gradient dla każdego parametru
Przyjrzymy się bliżej temu mechanizmowi w kolejnym wpisie, gdzie stworzymy prostą sieć neuronową od zera.
Podsumowanie
Backpropagation to eleganckie zastosowanie reguły łańcuchowej do efektywnego obliczania gradientów w grafach obliczeniowych. Kluczowe elementy:
- Forward pass — oblicz wartości od wejść do wyjścia, zapamiętaj pośrednie
- Backward pass — propaguj gradienty od wyjścia do wejść używając reguły łańcuchowej
- Gradient descent — zaktualizuj parametry: \(\theta = \theta - \eta \cdot \nabla_\theta L\)
- Iteracja — powtarzaj aż do zbieżności
Ten prosty algorytm, zaimplementowany efektywnie na GPU, umożliwia trenowanie modeli o miliardach parametrów.