Jak uczą się sieci neuronowe: dogłębnie o propagacji wstecznej i spadku gradientu

Sieć neuronowa to po prostu stos liczb — miliony albo miliardy parametrów (wag i biasów) zorganizowanych w warstwy. Różne układy warstw dają różne architektury (CNN, transformery itd.), ale u podstaw wszystko to parametry, które decydują o tym, co sieć robi. Nietrenowana sieć produkuje losowy szum. Wytrenowana rozpoznaje twarze, tłumaczy język albo pisze kod. Różnicą są wartości tych parametrów.

Trening to proces znajdowania właściwych wartości dla tych parametrów. Sieć robi predykcję, mierzy, jak bardzo się myli, a potem dostraja parametry, żeby następnym razem pomylić się mniej. Algorytm, który ustala, w którym kierunku dostroić każdy parametr, nazywa się propagacją wsteczną, a algorytm, który tę korektę faktycznie stosuje, nazywa się spadkiem gradientu. Razem stanowią silnik praktycznie całego współczesnego uczenia głębokiego.

Oto jak to wygląda na najprostszym możliwym przykładzie — model uczy się dopasować prostą. Kliknij Step kilka razy i patrz:

step 0 · loss = 28.00

Każde kliknięcie uruchamia jedną rundę tego samego algorytmu, który trenuje każdą sieć neuronową — od najmniejszego przykładu z zajęć po GPT. Model robi predykcję, mierzy, jak bardzo się myli (czerwone linie przerywane), oblicza, w którą stronę dostroić parametry, i lekko je przesuwa. Tyle. Powtórz to wystarczająco wiele razy, a prosta wskoczy na dane.

Ten artykuł jest o tym, żeby dokładnie zrozumieć, co dzieje się wewnątrz każdego z tych kroków. Zaczniemy od pojedynczego neuronu i dojdziemy aż do sieci wielowarstwowych — z interaktywnymi widgetami i kodem w Pythonie na każdym kroku, żebyś mógł eksperymentować z pojęciami bezpośrednio.

To długi artykuł — nie jest pomyślany do przeczytania za jednym posiedzeniem. Bierz go po kawałku: zacznij od podstaw (czym jest neuron, jak działa strata), pobaw się widgetami, aż zaskoczą, a potem wróć po rachunek różniczkowy i regułę łańcuchową, gdy będziesz gotowy. Każda sekcja opiera się na poprzedniej, więc jeśli coś wydaje się niejasne, warto cofnąć się i przeczytać poprzednią raz jeszcze.

Sieć neuronowa to po prostu parametry

Sieć neuronowa jest zbudowana z warstw, a każda warstwa z neuronów. Neuron to najmniejsza jednostka obliczeniowa — bierze jakieś wejścia, wykonuje prostą kalkulację i produkuje jedno wyjście. Ustaw wiele neuronów obok siebie, a dostaniesz warstwę. Ustaw kilka warstw jedna za drugą — tak że wyjście jednej zasila wejście następnej — a dostaniesz sieć neuronową. Cała sieć, niezależnie od rozmiaru, to te same małe kawałki, powtórzone i połączone. Żeby więc zrozumieć całość, możemy zacząć od zrozumienia jednego neuronu.

Pobawmy się tym w widgecie poniżej, żeby zbudować intuicję. To pojedynczy neuron z 3 wejściami. Po lewej sterujesz wartościami wejść, po prawej ustawiasz parametry neuronu — jego wektor wag, bias i funkcję aktywacji. Diagram aktualizuje się na żywo przy każdej zmianie.

w₁x₁ + w₂x₂ + w₃x₃ + b → activation(sum) → output
0.5·1 + 0.5·0 + 0.5·1 + 0.0 = 1.0 → perceptron(1.0) = 1
inputs
1.0
0.0
1.0
neuron parameters
0.5
0.5
0.5
0.0

Na widgecie widać, jak neuron mnoży każde wejście przez odpowiadającą mu wagę, sumuje wszystko wraz z biasem i przepuszcza wynik przez funkcję aktywacji.

Matematycznie robimy więc to:

output=f(w1x1+w2x2+w3x3+b)\text{output} = f(w_1 x_1 + w_2 x_2 + w_3 x_3 + b)

gdzie ff jest funkcją aktywacji. Każde wejście xix_i zostaje pomnożone przez odpowiadającą mu wagę wiw_i, iloczyny są sumowane razem z biasem bb, a wynik przechodzi przez funkcję aktywacji ff. To wszystko, co robi neuron — pomnóż, zsumuj, aktywuj.

Wypróbuj kilka konfiguracji, żeby zbudować intuicję:

  • Waga decyduje o tym, jak bardzo dane wejście się liczy. Ustaw x₁ = 1.0, resztę na 0. Teraz przeciągnij w₁ — wyjście reaguje wprost. Ustaw w₁ = 0 i to wejście jest całkowicie ignorowane, niezależnie od wartości.
  • Ujemne wagi hamują. Ustaw x₁ = 1.0, w₁ = -1.5, resztę na 0. Suma ważona schodzi poniżej zera — przy aktywacji perceptronowej wyjście wynosi 0. Neuron aktywnie tłumi to wejście.
  • Bias przesuwa granicę decyzyjną. Przy wszystkich wejściach równych 0 sumę wyznacza wyłącznie bias. Dodatni bias oznacza, że neuron odpala nawet bez wejścia. Ujemny oznacza, że wejścia muszą go „przezwyciężyć”, zanim neuron się aktywuje.
  • Funkcja aktywacji kształtuje wyjście. Przełącz się z Perceptron (twarde 0/1) na Sigmoid — teraz wyjście jest gładką wartością między 0 a 1. Spróbuj ReLU — przepuszcza wartości dodatnie bez zmian, a ujemne obcina do zera.

Dlaczego funkcje aktywacji mają znaczenie? Bez nich każdy neuron jest tylko funkcją liniową (pomnóż i dodaj), a składanie funkcji liniowych wciąż daje funkcję liniową — niezależnie od tego, ile warstw dołożysz. Funkcje aktywacji wprowadzają nieliniowość, która pozwala sieciom neuronowym uczyć się krzywych, krawędzi i złożonych wzorców zamiast samych prostych. Co więcej, sieć neuronowa z dostateczną liczbą neuronów i nieliniowymi aktywacjami potrafi przybliżyć praktycznie dowolną funkcję — jest to znane jako twierdzenie o uniwersalnej aproksymacji. To właśnie funkcja aktywacji to umożliwia. Piękne wizualne wyjaśnienie tego, jak sieci używają tych nieliniowych przekształceń, by wyginać i zaginać przestrzeń wejściową, aż złożone wzorce staną się rozdzielalne, znajdziesz u Chrisa Olaha: Neural Networks, Manifolds, and Topology.

Wagi i bias to parametry neuronu — wartości, których musi się nauczyć. Neuron przechowuje jedną wagę na wejście — w naszym widgecie jest to wektor 3 wartości. Wagi decydują o tym, jak bardzo liczy się każde wejście, a bias przesuwa wynik w górę albo w dół. Razem te parametry definiują, na co neuron reaguje. Inne wagi i biasy sprawiają, że ten sam neuron wykrywa zupełnie inne wzorce w swoich wejściach.

Ponieważ mnożenie i sumowanie to po prostu iloczyn skalarny, zwykle zapisuje się to w postaci wektorowej:

output=f(wx+b)\text{output} = f(\mathbf{w} \cdot \mathbf{x} + b)

Tutaj x\mathbf{x} to wektor (lista) wszystkich wejść — np. [x1,x2,x3][x_1, x_2, x_3] — a w\mathbf{w} to wektor wszystkich wag — np. [w1,w2,w3][w_1, w_2, w_3]. Iloczyn skalarny wx\mathbf{w} \cdot \mathbf{x} mnoży każdą parę i sumuje wyniki: w1x1+w2x2+w3x3w_1 x_1 + w_2 x_2 + w_3 x_3.

W Pythonie mogłoby to wyglądać tak:

import numpy as np

class Neuron:
    def __init__(self, n_inputs):
        self.w = np.random.randn(n_inputs)   # w — weight vector, e.g. [w₁, w₂, w₃]
        self.b = 0.0                         # b — bias

    def forward(self, x):                    # x — input vector, e.g. [x₁, x₂, x₃]
        z = np.dot(self.w, x) + self.b       # w · x + b — dot product + bias
        return max(0, z)                     # f(z) — activation function (ReLU)

# Create a neuron with 3 inputs and run it
neuron = Neuron(3)
output = neuron.forward(np.array([1.0, 0.5, 0.7]))

Warstwa to kupka neuronów

Zbierz wiele takich neuronów razem, a dostaniesz warstwę. Złóż kilka warstw, a dostaniesz sieć neuronową. Oto mała — 2 wejścia, dwie ukryte warstwy po 3 neurony i 1 wyjście. Nazywa się je „ukrytymi”, bo widzisz tylko wejścia wchodzące i wyjście wychodzące — warstwy pomiędzy są wewnętrzne dla sieci, niewidoczne z zewnątrz:

x₁x₂yinputlayer 1layer 2output

Widzieliśmy wyżej, że każdy neuron trzyma zestaw wag dla wszystkich swoich wejść plus bias, opakowane w funkcję aktywacji:

output=f(w1x1+w2x2+w3x3+b)\text{output} = f(w_1 x_1 + w_2 x_2 + w_3 x_3 + b)

Warstwa zwykle przechowuje je wszystkie razem w macierzy wag (WW) — po jednym wierszu wag na neuron. Dla sieci narysowanej powyżej layer1.W to macierz 3×2 — 3 neurony, każdy z 2 wagami, bo każdy neuron dostaje 2 wejścia:

layer1.W = [[ 0.4, -0.2],    ← neuron 0: weights for x₁, x₂
            [ 0.1,  0.7],    ← neuron 1: weights for x₁, x₂
            [-0.3,  0.5]]    ← neuron 2: weights for x₁, x₂

Dla pojedynczego neuronu mieliśmy iloczyn skalarny jednego wektora wag z wejściem: f(wx+b)f(\mathbf{w} \cdot \mathbf{x} + b). Dla całej warstwy układamy wszystkie wektory wag w macierz WW, a wszystkie biasy w wektor b\mathbf{b}, dzięki czemu ta sama operacja obejmuje wszystkie neurony naraz:

output=f(Wx+b)\text{output} = f(W\mathbf{x} + \mathbf{b})

Kiedy liczymy WxW\mathbf{x}, każdy wiersz WW zostaje przemnożony skalarnie przez wejście — to suma ważona jednego neuronu. Mnożenie macierzowe wykonuje je wszystkie w jednej operacji.

single neuron (dot product)w₁w₂w₃w·x₁x₂x₃x=outone row → one outputstack 3 neuronsfull layer (matrix multiply)0.4-0.2← n₀0.10.7← n₁-0.30.5← n₂W@x₁x₂x=w₀·xw₁·xw₂·x← neuron 0← neuron 1← neuron 2

W Pythonie wygląda to tak:

import numpy as np

class Layer:
    def __init__(self, n_inputs, n_neurons):
        # W is a matrix where each ROW is one neuron's weights.
        # Shape: (n_neurons, n_inputs) — so W[0] is neuron 0's weights,
        # W[1] is neuron 1's weights, etc.
        self.W = np.random.randn(n_neurons, n_inputs)
        self.b = np.zeros(n_neurons)  # b — bias vector, one per neuron

    def forward(self, x):
        # W @ x multiplies every neuron's weight row by the input,
        # computing all dot products at once
        return np.maximum(0, self.W @ x + self.b)  # ReLU activation

# Build the network from the diagram above
layer1 = Layer(2, 3)   # 2 inputs  → 3 neurons  (6 weights + 3 biases = 9)
layer2 = Layer(3, 3)   # 3 inputs  → 3 neurons  (9 weights + 3 biases = 12)
output = Layer(3, 1)   # 3 inputs  → 1 neuron   (3 weights + 1 bias   = 4)

# Forward pass — each layer's output feeds into the next
x = np.array([0.5, 0.8])
h1 = layer1.forward(x)       # input → hidden layer 1
h2 = layer2.forward(h1)      # hidden layer 1 → hidden layer 2
y  = output.forward(h2)      # hidden layer 2 → output

Kiedy liczymy W @ x, każdy wiersz zostaje przemnożony skalarnie przez wejście — wszystkie neurony warstwy policzone w jednej operacji. Dlatego sieci neuronowe korzystają z algebry liniowej i dlatego GPU (zbudowane do matematyki macierzowej) przyspieszają trening.

Każda waga i każdy bias to parametr. Policz je w sieci powyżej — warstwa 1 ma 9, warstwa 2 ma 12, wyjściowa 4 — i wychodzi 25 parametrów łącznie. To malutka sieć. GPT-2 miał około 1,5 miliarda parametrów; GPT-3 miał 175 miliardów. Badania nad prawami skalowania pokazały, że jakość modelu zwykle poprawia się przewidywalnie wraz ze wzrostem rozmiaru modelu, ilości danych treningowych i mocy obliczeniowej — dlatego dziedzina wciąż podbija te liczby. Choć są oznaki, że samo dokładanie parametrów zaczyna dawać coraz mniejszy zwrot, a punkt ciężkości przesuwa się ku lepszym danym treningowym, wydajniejszym architekturom i technikom takim jak rozumowanie i łańcuch myśli, które wyciskają więcej z istniejących rozmiarów modeli.

Żeby wyczuć skalę obliczeń: wytrenowanie GPT-3 wymagało mniej więcej 3 × 10²³ operacji. Przy miliardzie operacji na sekundę to 10 milionów lat na jednym procesorze. Tysiące GPU pracujących równolegle sprowadziły to do tygodni.

Jak sieć się uczy?

Trenowanie sieci neuronowej to znajdowanie właściwych wartości dla wszystkich tych parametrów — każdej wagi w macierzy każdej warstwy, każdego biasu w wektorze każdej warstwy. Na początku treningu te parametry są inicjalizowane małymi losowymi wartościami — sieć dosłownie nic nie wie. Trening to proces iteracyjnego dostrajania tych losowych liczb, aż zaczną produkować użyteczne predykcje.

Ogólna pętla treningowa, powtarzana w każdej iteracji, wygląda tak:

EtapCo robi
1. Przejście w przódPrzepuść wejścia przez każdą warstwę sieci, mnożąc przez wagi i stosując aktywacje, żeby otrzymać predykcję
2. Obliczenie stratyPorównaj predykcję z rzeczywistą wartością docelową za pomocą funkcji straty (np. MSE), która sprowadza wszystkie błędy do jednej liczby — jak bardzo model się myli?
3. Propagacja wstecznaPrzejdź przez sieć wstecz, używając reguły łańcuchowej, żeby policzyć gradient dla każdego parametru — w którą stronę ma się ruszyć i o ile?
4. Spadek gradientuZaktualizuj każdy parametr, odejmując małą część (współczynnik uczenia) jego gradientu, pchając całą sieć ku niższej stracie

Te cztery etapy dzielą się na dwie duże części. Przejście w przód (etap 1) przepuszcza wejścia przez sieć, żeby otrzymać predykcję. Gdy model jest wdrożony do inferencji — robienia predykcji na produkcji — działa tylko przejście w przód. Żadne wagi nie są aktualizowane.

Trening (etapy 2, 3 i 4) to wszystko, co dzieje się po predykcji: mierzenie błędu, ustalanie, w którą stronę dostroić każdy parametr, i zastosowanie aktualizacji. Te trzy etapy współpracują ze sobą i to one są tematem tego artykułu.

Proces ma dwa kierunki — dane płyną w przód, żeby dać predykcję, a potem gradienty płyną wstecz, żeby zaktualizować wagi:

  Forward pass: each layer receives activations, passes output →

              activations    activations    activations
  Input ─────────▶ Layer 1 ─────────▶ Layer 2 ─────────▶ Output ──▶ Loss


  Backward pass: each layer receives gradient signal, passes it ←

              gradients      gradients      gradients
  Input ◀───────── Layer 1 ◀───────── Layer 2 ◀───────── Output ◀── ∂L
              ↓ ∂L/∂W₁           ↓ ∂L/∂W₂           ↓ ∂L/∂W₃
           (own weight       (own weight         (own weight
            gradients)        gradients)          gradients)

Zauważ symetrię: w przejściu w przód każda warstwa dostaje aktywacje od poprzedniej warstwy i przekazuje swoje wyjście dalej. W przejściu wstecz każda warstwa dostaje sygnał gradientu od następnej warstwy i przekazuje go wstecz. W obu kierunkach każda warstwa potrzebuje wejścia od sąsiada, żeby wykonać swoją pracę.

Prosty przykład: dopasowanie prostej

Żeby zrozumieć, jak to działa, zredukujemy wszystko do najprostszej możliwej sieci: jeden neuron, jedno wejście, jedna waga, jeden bias. Kiedy zobaczysz, jak spadek gradientu działa przy 2 parametrach (jednej wadze i jednym biasie), skok do 25 albo 25 miliardów to już tylko kwestia skali.

Przypomnij sobie, co liczy pojedynczy neuron: f(wx+b)f(w \cdot x + b), gdzie xx to wektor wejść, a ww to wektor wag. Zredukuj to do jednego wejścia i pomiń funkcję aktywacji, a dostaniesz f(x)=wx+bf(x) = wx + b — podstawową funkcję liniową ze szkoły. To nie jest uproszczenie; to dosłownie to, co robi neuron przed nieliniowością wnoszoną przez funkcje aktywacji. Trenowanie jednego neuronu, by dopasował prostą, jest więc najczystszą wersją problemu.

Załóżmy, że ktoś podaje Ci pięć punktów i mówi, że pochodzą z funkcji liniowej f(x)=wx+bf(x) = wx + b, i prosi o znalezienie w oraz b:

x (wejście)-2-1012
y (wyjście)-3-1135

To odwrotność tego, co robiłeś w szkole. Na lekcji algebry dostajesz równanie w rodzaju y=3x+5y = 3x + 5 i masz „rozwiązać ze względu na xx” — parametry funkcji w=3w=3 i b=5b=5 są znane, szukasz wejścia xx. W naszym przypadku nie szukamy xx. Szukamy parametrów, które definiują samą funkcję — ww (nachylenia, czyli jak stroma jest prosta) i bb (wyrazu wolnego, czyli gdzie przecina oś y).

Ale po co szukać funkcji, skoro mamy już dane? Bo cały sens polega na obsłużeniu wejść, których nigdy nie widziałeś. Jeśli ktoś zapyta „jakie jest wyjście dla x = 1,5?”, a 1,5 nie ma w Twoich danych, tabela nie pomoże. Ale jeśli odkryłeś, że funkcją leżącą u podstaw jest f(x) = 2x + 1, odpowiesz natychmiast: 4. To jest generalizacja — zdolność do trafnych predykcji na nowych, niewidzianych wejściach.

Nanieśmy więc te punkty na wykres (zielone kropki) i spróbujmy znaleźć w i b ręcznie, przesuwając suwaki. Kieruj się wartością straty — przeciągaj i sprawdź, czy uda Ci się zbić stratę do zera. Odkryjesz, że w=2w = 2 i b=1b = 1 sprowadzają stratę do zera — to dokładnie te parametry, które wygenerowały dane, czyli y=2x+1y = 2x + 1:

0.0
0.0

Kierowaliśmy się stratą, żeby prowadzić poszukiwania — ale czym ona jest? Strata to jedna liczba, która mówi, jak bardzo model myli się ogólnie. Gdy strata jest wysoka, predykcje są dalekie od danych. Gdy wynosi zero, model pasuje idealnie.

Przy przesuwaniu suwaków zwróć uwagę na czerwone linie przerywane — to poszczególne błędy w każdym punkcie danych, pokazujące, jak daleko predykcja jest od rzeczywistej wartości. Błąd dla każdego punktu liczymy jako error=predictionactualerror = prediction - actual.

Trenując model, potrzebujemy jednej liczby mówiącej, jak bardzo predykcja jest błędna ogółem. To właśnie strata — funkcja, która bierze wszystkie błędy i sprowadza je do jednego wyniku. W uczeniu maszynowym jest wiele funkcji straty, każda pasuje do innych zadań:

  • Błąd średniokwadratowy (MSE) — dla regresji (przewidywania liczb). Podnosi każdy błąd do kwadratu i uśrednia.
  • Entropia krzyżowa — dla klasyfikacji (przewidywania kategorii). Mierzy, jak daleko przewidziane prawdopodobieństwa są od prawdziwych etykiet.
  • Średni błąd bezwzględny (MAE) — jak MSE, ale z wartościami bezwzględnymi zamiast kwadratów, mniej wrażliwy na wartości odstające.

Ponieważ dopasowujemy prostą — zadanie znane jako regresja (przewidywanie liczby ciągłej) — użyliśmy błędu średniokwadratowego (MSE): weź każdy błąd, podnieś do kwadratu, a potem wszystko uśrednij. Podnoszenie do kwadratu robi dwie rzeczy — sprawia, że wszystkie błędy są dodatnie (więc się nie znoszą) i karze duże błędy znacznie mocniej niż małe:

MSE=1ni=1n(yprediyactuali)2=(ypred1yactual1)2+(ypred2yactual2)2++(yprednyactualn)2nMSE = \frac{1}{n} \sum_{i=1}^{n} (y_{\text{pred}_i} - y_{\text{actual}_i})^2 = \frac{(y_{\text{pred}_1} - y_{\text{actual}_1})^2 + (y_{\text{pred}_2} - y_{\text{actual}_2})^2 + \cdots + (y_{\text{pred}_n} - y_{\text{actual}_n})^2}{n}

Zastosujmy ten wzór do naszych danych. Powiedzmy, że Twoje bieżące zgadywanie to w=3w = 3, b=1b = 1, czyli f(x)=3x+1f(x) = 3x + 1. Dla każdego z naszych 5 punktów liczymy predykcję, błąd (jak bardzo obok) i błąd podniesiony do kwadratu:

xxyactualy_{\text{actual}}ypred=3x+1y_{\text{pred}} = 3x + 1błądbłąd²
-2-3-5-24
-1-1-2-11
01100
13411
25724
średnia →strata = 2.0

Podnieś każdy błąd do kwadratu (żeby ujemne się nie zniosły), potem uśrednij. Wynikiem jest jedna liczba: strata 2.0 oznacza, że nasze predykcje mijają się średnio o około 1,4 (21,4\sqrt{2} \approx 1{,}4). Więcej znaczy gorsze dopasowanie, zero znaczy idealne. Kiedy w widgecie powyżej ustawiliśmy w=2w = 2 i b=1b = 1, strata spadła do zera, bo to dokładnie te parametry, które wygenerowały dane.

Udało nam się znaleźć parametry ręcznie dla naszej prostej funkcji o 2 parametrach, ale wyobraź sobie robienie tego przy 25 parametrach, nie mówiąc o milionach. Później zastosujemy wszystko z tego artykułu do prawdziwego zadania — trenowania sieci do klasyfikacji odręcznych cyfr z ponad 100 000 parametrów. Ręczne strojenie w tej skali jest niemożliwe — żaden człowiek nie przeszuka przestrzeni o milionach wymiarów. Potrzebujemy systematycznego sposobu, by spojrzeć na stratę i matematycznie ustalić, w którą stronę pchnąć każdy parametr, żeby ją zmniejszyć. Dokładnie to robią razem propagacja wsteczna i spadek gradientu: propagacja wsteczna liczy, w którą stronę dostroić każdy parametr, a spadek gradientu robi mały krok w tym kierunku. Powtarzamy ten proces, aż strata będzie minimalna.

Dla prostego modelu liniowego istnieje dokładny wzór — równanie normalne — który daje idealne w i b za jednym zamachem, bez iteracji. Ale działa tylko dla modeli liniowych. W chwili, gdy pojawiają się nieliniowe aktywacje, wiele warstw i miliony parametrów, żadnego wzoru nie ma. Spadek gradientu go nie potrzebuje — potrzebuje jedynie zmierzyć błąd i policzyć, w którą stronę pchnąć. Działa dla dowolnego modelu różniczkowalnego i dlatego jest uniwersalnym algorytmem treningowym.

Pętla treningowa krok po kroku

Zobaczmy najpierw, jak działa ten automatyczny algorytm. Widget poniżej pozwala uruchomić propagację wsteczną i spadek gradientu dla naszego zadania krok po kroku i obserwować wszystko na bieżąco:

  • Lewy wykres: punkty danych (zielone kropki) i linia predykcji modelu (niebieska) na podstawie bieżących wartości ww i bb. Czerwone linie przerywane pokazują błąd w każdym punkcie — różnicę między predykcją a rzeczywistą wartością. Te błędy są podnoszone do kwadratu i uśredniane, dając stratę MSE.
  • Prawy wykres: strata naniesiona dla każdego kroku — tak monitoruje się, czy trening działa. Stale opadająca krzywa oznacza, że model się uczy; jeśli wypłaszcza się albo skacze, coś wymaga korekty.

Kliknij Step, żeby wykonać jedną aktualizację spadku gradientu, albo Step x10, żeby zrobić dziesięć naraz.

Backpropagation

-3.0
3.0

Gradient Descent

0.10

Computation (step 0)

Po jednym kroku prosta wciąż jest zła, ale mniej zła. Uruchom ponownie. I jeszcze raz. Każdy krok zmniejsza błąd, gradienty maleją, a prosta podpełza do celu. Rozwiń sekcję „Computation”, żeby zobaczyć liczby na każdym kroku. Na razie zostaw współczynnik uczenia na domyślnej wartości (0.1) — co robi i jak go dobierać, sprawdzimy w następnej sekcji.

Klikając dalej Step, patrz na prawy wykres — strata najpierw spada stromo (model szybko koryguje swoje najgorsze pomyłki, bo gradienty są duże), a potem wypłaszcza się, gdy zbliża się do odpowiedzi (mniejsze błędy oznaczają mniejsze gradienty, więc każdy krok robi mniej). Nazywa się to zbieżnością — model osiada na właściwych parametrach.

Dzieje się tak, mimo że współczynnik uczenia przez cały czas pozostaje stały. Pamiętaj, gradient to dw = 2 * mean(error * x) — jest liczony z błędów. Gdy model zbliża się do właściwej odpowiedzi, błędy maleją, co zmniejsza gradient, co zmniejsza aktualizację lr * dw. Współczynnik uczenia się nie zmienia, ale kroki maleją automatycznie, bo jest mniej błędu do skorygowania. To samoregulujące się zachowanie jest znakiem firmowym spadku gradientu: jest szybki wtedy, gdy to najważniejsze, a potem ostrożnie dostraja bez żadnej Twojej ingerencji.

Każde kliknięcie „Step” uruchamia jedną pełną iterację treningu — cztery kroki odpowiadające opisanej wcześniej pętli, która w Pythonie wygląda tak:

 # 1. forward pass
y_pred = w * x + b

# 2. loss computation
error  = y_pred - y
loss   = np.mean(error ** 2)

# 3. backpropagation
dw = 2 * np.mean(error * x)
db = 2 * np.mean(error)

# 4. gradient descent
w = w - lr * dw
b = b - lr * db

Kroki 1 i 2 to przejście w przód i strata. Przejście w przód przepuszcza każde wejście przez y = wx + b, żeby dostać predykcję (predykcje dla poszczególnych wejść widać w panelu Computation). Obliczenie straty mierzy potem, jak bardzo się mylimy — różnicę między każdą predykcją a rzeczywistą wartością, podniesioną do kwadratu i uśrednioną w jedną liczbę (MSE). Jak działa MSE, omówiliśmy w poprzedniej sekcji.

Kroki 3 i 4 to miejsce, gdzie zachodzi uczenie. Propagacja wsteczna (krok 3) liczy gradient dla każdego parametru — w którą stronę go pchnąć i o ile. Spadek gradientu (krok 4) stosuje te gradienty, odejmując od każdego parametru małą część (współczynnik uczenia). Zanim zanurzymy się w propagację wsteczną, gdzie mieszka większość złożoności, dotknijmy krótko współczynnika uczenia — tego lr z kroku 4.

Obliczanie współczynnika uczenia

W sekcji obliczeń powyżej widać, że spadek gradientu aktualizuje parametry tak:

w = w - lr * dw
b = b - lr * db

Gradient (dw, db) mówi nam, w którą stronę przesunąć każdy parametr i o ile względem pozostałych. Ale jak daleko właściwie stawiać krok? Tym steruje współczynnik uczenia (lr) — skaluje każdy gradient przed jego zastosowaniem.

Kierunek zawsze jest poprawny — ale rozmiar kroku może być zły. Jeśli lr jest za małe, każdy krok ledwo się rusza i trening trwa wieczność. Jeśli lr jest za duże, przestrzelisz minimum i wylądujesz po drugiej stronie, gdzie strata jest gorsza niż przedtem. Pomyśl o współczynniku uczenia jak o pokrętle pewności siebie:

  • Za małe (spróbuj 0.01) — każdy krok jest maleńki. Model pełznie do odpowiedzi, potrzebując setek kroków. Bezpiecznie, ale boleśnie wolno.
  • W sam raz (spróbuj 0.1) — model stawia pewne kroki, zbiegając w 20–30 krokach. Strata najpierw szybko spada, potem następuje dostrajanie.
  • Trochę za duże (spróbuj 0.5) — model przestrzeliwuje minimum, odbijając się w tę i z powrotem. Ale każde przestrzelenie ląduje bliżej dna, gdzie gradienty są mniejsze, więc odbicia maleją i model i tak zbiega — tylko zygzakiem i w większej liczbie kroków niż przy lr = 0.1.
  • Za duże (spróbuj 1.0) — przestrzeliwanie robi się ostrzejsze. Każdy krok ląduje daleko od minimum, gdzie gradient wciąż jest duży, co powoduje kolejny duży krok. Model może nadal zbiec, ale robi to nerwowo i marnotrawnie.
  • Zdecydowanie za duże (spróbuj 1.5) — przestrzeliwanie jest tak ekstremalne, że każdy krok ląduje gorzej niż poprzedni. Gradient rośnie zamiast maleć, więc następny krok jest jeszcze większy — pętla sprzężenia zwrotnego, która wysyła stratę spiralą w górę. Nazywa się to rozbieżnością.

Wypróbuj sam — zmień współczynnik uczenia i kliknij Step x10, żeby zobaczyć efekt:

0.10

Nie ma wzoru na „właściwy” współczynnik uczenia. W praktyce większość ludzi zaczyna od typowej wartości domyślnej (0.001 albo 0.0001), używa adaptacyjnego optymalizatora takiego jak Adam, który automatycznie dostosowuje rozmiar kroku dla każdego parametru na podstawie zachowania jego gradientów, i stosuje harmonogram współczynnika uczenia, który zaczyna od dużych wartości (wielkie kroki, by zgrubnie się zbliżyć) i zmniejsza je w trakcie treningu (małe kroki na dostrojenie). Prawie wszyscy używają Adama albo jego wariantu zamiast czystego spadku gradientu.

To wszystko są udoskonalenia nad tą samą 4-etapową pętlą treningową. Rdzeń algorytmu się nie zmienia.

Obliczanie propagacji wstecznej

W sekcji obliczeń powyżej widać, że propagacja wsteczna liczy gradienty tak:

dw = 2 * np.mean(error * x)
db = 2 * np.mean(error)

W tych dwóch linijkach upakowano sporo. Dlaczego przy dw mnożymy error przez x, a przy db nie? Skąd bierze się 2? Co ma do tego mean? Rozłóżmy to krok po kroku.

Pamiętaj, strata jest liczona z predykcji, a predykcje zależą od w i b. Ostatecznie strata jest funkcją parametrów — zmień w albo b, a strata się zmieni. Przeciągnij w albo b w widgecie poniżej i popatrz, jak zmienia się strata — biała kropka przesuwa się po krzywej, pokazując dokładnie, gdzie jesteś na krajobrazie straty:

-3.0
3.0

Spróbuj przeciągnąć w — kropka przesuwa się po lewej krzywej, ale prawa krzywa zmienia kształt. Dlaczego? Prawy wykres pyta „dla każdego możliwego b, jaka jest strata?” — przy w ustalonym na tym, co mówi suwak. Kiedy zmieniasz w, zmieniasz tę ustaloną wartość, co zmienia błędy przy każdym b, dając zupełnie inną krzywą. To samo działa w drugą stronę: przeciągnij b, a kształt zmieni lewa krzywa. Najlepsza wartość w zależy od tego, gdzie jest b, i odwrotnie — są sprzężone.

Wzory na gradient biorą się z policzenia pochodnej tych krzywych — z pomiaru, jak bardzo zmienia się strata, gdy pchniesz każdy parametr o odrobinę. Zatem 2 * mean(error * x) to po prostu pochodna funkcji straty względem w.

Żeby zrozumieć, jak przechodzimy od funkcji straty do 2 * mean(error * x), potrzebujemy trzech pojęć budujących się na sobie:

  1. Pochodne — co znaczy zmierzyć, jak zmienia się funkcja
  2. Reguła łańcuchowa — jak liczyć pochodne, gdy funkcje są ze sobą złożone
  3. Pochodne cząstkowe i gradienty — jak obsłużyć wiele parametrów naraz

Na koniec prześledzimy dokładnie, skąd bierze się każdy element tego wzoru. Zacznijmy od tego, czym właściwie jest pochodna.

Wzór, który przed chwilą zobaczyliśmy — 2 * mean(error * x) — jest specyficzny dla straty MSE z modelem liniowym. Inne funkcje straty i architektury dają inne wzory na gradient — ale leżące u podstaw zasady matematyczne są zawsze te same. Dla naszego prostego modelu możemy wyprowadzić wzór ręcznie; dla sieci głębokich z milionami parametrów frameworki takie jak PyTorch i TensorFlow liczą pochodne automatycznie za pomocą autograda (różniczkowania automatycznego).

Pochodna: nachylenie w punkcie

Pochodna odpowiada na jedno pytanie: jeśli pchnę to wejście o odrobinę, o ile zmieni się wyjście? Pomyśl o niej jak o nachyleniu krzywej w jednym punkcie. Jeśli stoisz na wzgórzu, pochodna mówi Ci, jak stromy jest grunt pod stopami — i w którą stronę idzie w dół.

Weź prostą funkcję jak f(x) = x². Przeciągnij punkt x po krzywej i zobacz, jak zmienia się nachylenie i pochodna:

At x=1.0 derivative is 2.0
1.0
0.80

Computation

Ustaw w widgecie x = 2 i dx = 0.5. Żółta linia (dxdx) to pchnięcie wejścia, zielona linia (dfdf) to o ile w odpowiedzi zmienia się wyjście. Sekcja Computation pod wykresem pokazuje, jak łączą się one w pochodną.

Najpierw obliczamy funkcję w naszym punkcie: f(2)=4f(2) = 4. Potem pchamy wejście o dx i liczymy jeszcze raz: f(2.5)=6.25f(2.5) = 6.25. Różnica mówi nam, o ile zmieniło się wyjście: df=6.254=2.25df = 6.25 - 4 = 2.25. Dzielenie przez pchnięcie daje tempo zmiany: df/dx=2.25/0.5=4.5df/dx = 2.25 / 0.5 = 4.5.

Ten stosunek (4,5) to w przybliżeniu pochodna w x = 2 — mówi o tempie: w tym punkcie wyjście zmienia się mniej więcej 4 razy szybciej niż wejście. Nie jest to dokładnie 4, bo dx = 0.5 to wciąż duże pchnięcie. Zmniejszmy teraz dx — spróbuj zjechać do 0.1:

  • f(2)=4f(2) = 4
  • f(2.1)=4.41f(2.1) = 4.41
  • df=0.41df = 0.41
  • df/dx=0.41/0.1=4.1df/dx = 0.41 / 0.1 = 4.1 — bliżej 4

Gdy dx maleje, stosunek zbiega do dokładnej pochodnej.

Na tym polega cała idea — pochodna to wartość, do której zmierza df/dxdf/dx, gdy dxdx maleje do zera: dokładne tempo zmiany w jednym punkcie.

Ogólny wzór wygląda skomplikowanie, ale to dokładnie to, co przed chwilą zrobiliśmy:

f(x)=limdx0f(x+dx)f(x)dxf'(x) = \lim_{dx \to 0} \frac{f(x + dx) - f(x)}{dx}

f(x+dx)f(x)f(x + dx) - f(x) to zmiana wyjścia (dfdf). Podziel przez dxdx, żeby dostać stosunek. Część limdx0\lim_{dx \to 0} znaczy po prostu „zmniejszaj dxdx do zera” — dokładnie to, co robiłeś suwakiem, patrząc, jak stosunek zbiega do dokładnej wartości.

Dla f(x)=x2f(x) = x^2 możemy to rozpisać:

f(x+dx)=(x+dx)2=x2+2xdx+dx2f(x + dx) = (x + dx)^2 = x^2 + 2x \cdot dx + dx^2 f(x+dx)f(x)=2xdx+dx2f(x + dx) - f(x) = 2x \cdot dx + dx^2 f(x+dx)f(x)dx=2x+dx\frac{f(x + dx) - f(x)}{dx} = 2x + dx

Gdy dx0dx \to 0, zostaje po prostu 2x2x. Czyli dfdx=2x\frac{df}{dx} = 2x.

Na tym polega cały sens rachunku różniczkowego — eliminuje konieczność wybierania dxdx. Widget pokazuje, dlaczego można ufać dokładnemu wzorowi: jakie dxdx byś nie wybrał, stosunek zmierza do 2x2x w miarę jego zmniejszania. Pomijamy więc zmniejszanie i po prostu używamy 2x2x.

Jeśli chcesz zbudować głębsze zrozumienie pochodnych, The Essence of Calculus od 3Blue1Brown to najlepsze wyjaśnienie, jakie istnieje. Cała seria jest warta obejrzenia — buduje intuicję, którą podręczniki często pomijają.

Reguła łańcuchowa: pochodne funkcji złożonych

Umiemy policzyć pochodną prostej funkcji jak f(x)=x2f(x) = x^2. Ale co się dzieje, gdy jedna funkcja zasila drugą? Nazywa się to złożeniem funkcji — i dokładnie to robi nasze obliczenie:

y_pred = w * x + b             # prediction
error  = y_pred - y            # how far off
loss   = np.mean(error ** 2)   # squared error, averaged

Chcemy ustalić, jak pchnąć w, żeby zmniejszyć stratę, ale w zawiera tylko f1f_1. Musimy policzyć pochodną straty względem w, ale loss (f3f_3) nie przyjmuje w jako parametru — przyjmuje error. A error (f2f_2) też nie przyjmuje w — przyjmuje y_pred. Dopiero y_pred (f1f_1) przyjmuje w.

Widzimy więc, że policzenie straty z w to nie jedna funkcja — to łańcuch trzech funkcji, z których każda podaje swoje wyjście dalej:

wf1y_predf2errorf3error2w \xrightarrow{f_1} y\_pred \xrightarrow{f_2} error \xrightarrow{f_3} error^2

Rozpisując:

  • f1(w)=wx+bf_1(w) = w \cdot x + b — predykcja modelu
  • f2(y_pred)=y_predyf_2(y\_pred) = y\_pred - y — jak bardzo jesteśmy obok
  • f3(error)=error2f_3(error) = error^2 — błąd podniesiony do kwadratu (to, co chcemy minimalizować)

Strata to f3(f2(f1(w)))f_3(f_2(f_1(w))) — trzy funkcje zagnieżdżone jedna w drugiej.

Umiemy znaleźć pochodną każdej pojedynczej funkcji, ale jak je połączyć, żeby dostać pochodną całego łańcucha? Odpowiedzią jest reguła łańcuchowa: pomnóż pochodne lokalne przez siebie.

d(loss)dw=f1f2f3\frac{d(\text{loss})}{dw} = f'_1 \cdot f'_2 \cdot f'_3

Przypomnij sobie wzór na pochodną:

f(x)=limdx0f(x+dx)f(x)dxf'(x) = \lim_{dx \to 0} \frac{f(x + dx) - f(x)}{dx}.

Licznik f(x+dx)f(x)f(x + dx) - f(x) to zmiana wyjścia — dfdf. Mianownik dxdx to zmiana wejścia. Całość to więc dfdx\frac{df}{dx} — „zmiana ff podzielona przez zmianę xx”. To inny sposób zapisania f(x)f'(x). Wyjście na górze, wejście na dole:

  • f1f'_1: wyjściem jest y_predy\_pred, wejściem wwd(y_pred)dw\frac{d(y\_pred)}{dw}
  • f2f'_2: wyjściem jest errorerror, wejściem y_predy\_predd(error)d(y_pred)\frac{d(\text{error})}{d(y\_pred)}
  • f3f'_3: wyjściem jest error2error^2, wejściem errorerrord(error2)d(error)\frac{d(\text{error}^2)}{d(\text{error})}

W tym zapisie reguła łańcuchowa rozwija się do:

d(loss)dw=f1f2f3=d(y_pred)dwd(error)d(y_pred)d(error2)d(error)\frac{d(\text{loss})}{dw} = f'_1 \cdot f'_2 \cdot f'_3 = \frac{d(y\_pred)}{dw} \cdot \frac{d(\text{error})}{d(y\_pred)} \cdot \frac{d(\text{error}^2)}{d(\text{error})}

Dlaczego mnożenie? Bo każda funkcja jest zagnieżdżona w następnej — wyjście jednej staje się wejściem drugiej. Pomyśl o tym jak o łańcuchu pchnięć: jeśli pchniesz w o odrobinę, y_pred zmieni się o x razy to pchnięcie. Potem error zmieni się o 1 razy tyle, ile zmieniło się y_pred. Potem error² zmieni się o 2·error razy tyle, ile zmieniło się error. Każde ogniwo łańcucha skaluje pchnięcie — a skalowania składają się przez mnożenie.

Trzy sposoby łączenia funkcji

Są trzy podstawowe sposoby łączenia dwóch funkcji f(x)f(x) i g(x)g(x), a każdy ma własną regułę tego, jak łączą się pochodne:

  1. Dodawanie: h(x)=f(x)+g(x)h(x) = f(x) + g(x) — pochodne się dodają. Jeśli ff zmieni się o 3, a gg o 5, suma zmieni się o 8. To reguła sumy: h(x)=f(x)+g(x)h'(x) = f'(x) + g'(x).

  2. Mnożenie: h(x)=f(x)g(x)h(x) = f(x) \cdot g(x) — jest bardziej złożone, bo oba czynniki mogą się zmieniać. To reguła iloczynu: h(x)=f(x)g(x)+f(x)g(x)h'(x) = f'(x) \cdot g(x) + f(x) \cdot g'(x). Trzeba uwzględnić zmianę każdej funkcji przy drugiej trzymanej na stałe.

  3. Złożenie (zagnieżdżenie): h(x)=f(g(x))h(x) = f(g(x)) — wyjście gg zasila ff. Pochodne się mnożą. To reguła łańcuchowa: h(x)=f(g(x))g(x)h'(x) = f'(g(x)) \cdot g'(x). Pchnięcie xx zostaje przeskalowane przez gg', a potem ta przeskalowana zmiana jeszcze raz przez ff'.

Nasze obliczenie straty jest złożeniem — f3(f2(f1(w)))f_3(f_2(f_1(w))) — dlatego mnożymy pochodne. Gdyby funkcje były dodane albo pomnożone, użylibyśmy odpowiedniej reguły. W praktyce sieci neuronowe używają wszystkich trzech: dodawania (składniki biasu), mnożenia (wagi razy wejścia) i złożenia (warstwy zasilające się nawzajem). Propagacja wsteczna stosuje tę regułę, która pasuje do danej operacji.

Dobrze, policzmy więc złożoną pochodną naszego łańcucha straty. Pokazaliśmy, jak znaleźć pochodną x2x^2, co dało nam 2x2x. To samo podejście działa dla prostszych funkcji: pochodna ax+bax + b to po prostu aa (stały mnożnik), a pochodna xcx - c to 11 (odjęcie stałej nie zmienia tempa). Dzięki temu policzenie każdej pojedynczej pochodnej jest banalne:

  • Żeby policzyć f1=d(y_pred)dwf'_1 = \frac{d(y\_pred)}{dw}, korzystamy z tego, że pochodna ax+bax + b to aa. Skoro y_pred=wx+by\_pred = w \cdot x + b, pochodna wynosi x.
  • Żeby policzyć f2=d(error)d(y_pred)f'_2 = \frac{d(\text{error})}{d(y\_pred)}, korzystamy z tego, że pochodna xcx - c to 11. Skoro error=y_predyerror = y\_pred - y, pochodna wynosi 1.
  • Żeby policzyć f3=d(error2)d(error)f'_3 = \frac{d(\text{error}^2)}{d(\text{error})}, korzystamy z tego, że pochodna x2x^2 to 2x2x. Skoro funkcją jest error2error^2, pochodna wynosi 2 · error.

Co daje nam:

d(loss)dw=f1f2f3=x1(2error)=2errorx\frac{d(\text{loss})}{dw} = f'_1 \cdot f'_2 \cdot f'_3 = x \cdot 1 \cdot (2 \cdot error) = 2 \cdot error \cdot x

To 2errorx2 \cdot error \cdot x dla pojedynczego punktu danych.

Prześledźmy to na prawdziwych liczbach. Przy w = 3, b = 1 weźmy punkt x = 2, y = 5:

w = 3
  ↓  × x = ×2
y_pred = 3·2 + 1 = 7
  ↓  × 1
error = 7 - 5 = 2
  ↓  × 2·error = ×4
error² = 4

Reguła łańcuchowa: f1f2f3=214=8f'_1 \cdot f'_2 \cdot f'_3 = 2 \cdot 1 \cdot 4 = 8. Oznacza to, że jeśli pchniemy w o 1, kwadrat błędu w tym punkcie zmieni się o 8.

Ale mamy 5 punktów danych, nie jeden. Skoro MSE uśrednia kwadraty błędów po wszystkich punktach, musimy uśrednić także pochodne. Dla każdego punktu liczymy 2errorx2 \cdot error \cdot x:

xxyyypred=3x+1y_{pred} = 3x + 1errorerror2errorx2 \cdot error \cdot x
-2-3-5-22(2)(2)=82 \cdot (-2) \cdot (-2) = 8
-1-1-2-12(1)(1)=22 \cdot (-1) \cdot (-1) = 2
0110200=02 \cdot 0 \cdot 0 = 0
1341211=22 \cdot 1 \cdot 1 = 2
2572222=82 \cdot 2 \cdot 2 = 8

Uśrednij je: 8+2+0+2+85=4\frac{8 + 2 + 0 + 2 + 8}{5} = 4. Czyli dw = 4 — gradient mówi nam, że strata rośnie, gdy zwiększamy w, więc powinniśmy je zmniejszyć. (I rzeczywiście, prawdziwa wartość to w = 2, czyli mniej niż nasze zgadywanie 3.)

W zapisie matematycznym to:

d(loss)dw=1ni=1n2errorixi=21ni=1nerrorixi\frac{d(\text{loss})}{dw} = \frac{1}{n} \sum_{i=1}^{n} 2 \cdot error_i \cdot x_i = 2 \cdot \frac{1}{n} \sum_{i=1}^{n} error_i \cdot x_i

A w Pythonie:

dw = 2 * np.mean(error * x)

Widget poniżej pozwala prześledzić ten łańcuch dla każdego punktu danych. Klikaj różne przyciski x=, żeby zobaczyć, jak zmieniają się pochodne lokalne — zauważ, że reguła łańcuchowa daje inną wartość dla każdego punktu, bo x i error są różne:

Tracing d(loss)/dw for data point:
w
y_pred = w·x + b
error = y_pred - y
error²
-3.0
3.0

Dla db łańcuch jest ten sam, tyle że f1f'_1 jest inne: skoro y_pred=wx+by\_pred = w \cdot x + b, pochodna względem b to po prostu 1 (zamiast x). Czyli:

d(loss)db=f1f2f3=11(2error)=2error\frac{d(\text{loss})}{db} = f'_1 \cdot f'_2 \cdot f'_3 = 1 \cdot 1 \cdot (2 \cdot error) = 2 \cdot error

A w naszym kodzie w Pythonie wygląda to tak:

db = 2 * np.mean(error)

Dlatego reguła łańcuchowa ma znaczenie: za każdym razem, gdy strata jest liczona przez sekwencję operacji (a zawsze jest), potrzebujesz jej, żeby prześledzić wstecz, jak każdy parametr wpłynął na końcowy wynik. Dla naszego modelu o 2 parametrach łańcuch ma 3 kroki. Głęboka sieć neuronowa może mieć ich setki — po jednym na warstwę — ale zasada jest identyczna: każda warstwa to jeszcze jedna funkcja w złożeniu, jeszcze jedna pochodna lokalna do przemnożenia.

Stosujemy pochodne do naszej funkcji straty

Skoro wiemy już, jak liczyć pojedyncze pochodne i łączyć je regułą łańcuchową, zastosujmy tę wiedzę do naszego problemu. „Krzywą”, którą chcemy minimalizować, jest nasza funkcja straty — wzór wybrany do mierzenia błędu. Znamy tę funkcję dokładnie:

loss(w,b)=1ni=1n(wxi+byi)2loss(w, b) = \frac{1}{n}\sum_{i=1}^{n}(w \cdot x_i + b - y_i)^2

loss = np.mean((w * x + b - y) ** 2)

Nie wiemy natomiast, które wartości w i b czynią ją najmniejszą. Pochodna pomaga nam to ustalić: mówi nam, czy jeśli zwiększę w o odrobinę, strata pójdzie w górę czy w dół? I jak szybko?

Dane (x i y) są ustalone — to nasz zbiór treningowy. Jeśli na razie przytrzymamy także b na stałe (powiedzmy b = 3), to strata staje się funkcją samego w i możemy narysować ją jako zwykłą krzywą. Na przykład przy w = 0:

y_pred = w * x + b                  # 0 * [-2,-1,0,1,2] + 3 = [3, 3, 3, 3, 3]
error  = y_pred - y                 # [3,3,3,3,3] - [-3,-1,1,3,5] = [6, 4, 2, 0, -2]
loss   = np.mean(error ** 2)        # mean([36, 16, 4, 0, 4]) = 12.0

To daje nam jeden punkt krzywej: (w=0, loss=12). Zrób tak dla każdego w od -5 do 5 (trzymając b = 3 na stałe), a dostaniesz pełny obraz — stratę jako funkcję samego w:

0.0(b = 3.0 fixed)

Oś x to w (testowana wartość parametru), oś y to strata (jak bardzo model myli się przy tym w). Wynikiem jest parabola — a jej najniższy punkt jest przy w = 2, gdzie strata spada do zera. Przeciągnij suwak w i obserwuj obliczenia poniżej — dla każdego z 5 punktów naszego zbioru treningowego (x = [-2, -1, 0, 1, 2]) liczy predykcję, błąd i kwadrat błędu, a potem uśrednia je w jedną wartość straty. To biała kropka na krzywej. (Zakres od -5 do 5 jest arbitralny — po prostu dość szeroki, by pokazać kształt i objąć minimum. Moglibyśmy przejechać od -100 do 100, ale krzywa byłaby zbyt oddalona, żeby dostrzec szczegóły.)

To samo możemy zrobić dla b — tym razem ustalając w = 2 i zmieniając b od -5 do 5:

3.0(w = 2.0 fixed)

Ten sam kształt paraboli, ale teraz na osi x jest b. Minimum jest przy b = 1, gdzie strata spada do zera. Razem w = 2 i b = 1 to dokładnie te parametry, które wygenerowały nasze dane — y = 2x + 1.

Zarówno dla w, jak i dla b liczyliśmy stratę w wielu wartościach z całego zakresu, żeby narysować pełną krzywą. Pomaga to zbudować intuicję — ale w praktyce nigdy byś tego nie robił. Przy 2 parametrach wypróbowanie każdej kombinacji jest trywialne. Ale prawdziwa sieć neuronowa ma miliony parametrów. Żeby narysować krajobraz straty, trzeba by wypróbować je wszystkie we wszystkich kombinacjach — niewyobrażalnie drogo. Dlatego potrzebujemy pochodnej: zamiast mapować całą krzywą w poszukiwaniu minimum, liczymy nachylenie w jednym punkcie i stawiamy krok w dół. Nigdy nie widzimy pełnego obrazu. Po prostu czujemy grunt pod stopami.

Pochodne cząstkowe

Zauważ, co przed chwilą zrobiliśmy: żeby zrozumieć, jak strata zależy od w, zamroziliśmy b i zmienialiśmy samo w. Żeby zrozumieć zależność od b, zamroziliśmy w i zmienialiśmy samo b. Dokładnie tym jest pochodna cząstkowa — pochodną straty względem jednego parametru przy pozostałych trzymanych na stałe:

  • ∂loss/∂w — jak zmienia się strata, gdy pchniesz w (przy zamrożonym b)
  • ∂loss/∂b — jak zmienia się strata, gdy pchniesz b (przy zamrożonym w)

Każda z dwóch krzywych powyżej to przekrój krajobrazu straty wzdłuż jednego parametru. Nachylenie tej krzywej w dowolnym punkcie jest pochodną cząstkową:

dw = 2 * np.mean(error * x)        # ∂loss/∂w — how loss changes with w
db = 2 * np.mean(error)            # ∂loss/∂b — how loss changes with b

Widget poniżej to połączony widok obu krzywych, które widzieliśmy wyżej, pokazujący teraz pochodne cząstkowe w akcji. Lewy wykres zmienia w (trzymając b na stałe), prawy wykres zmienia b (trzymając w na stałe). Na każdym wykresie biała kropka to miejsce, w którym jesteś, niebieska linia przerywana to styczna (jej nachylenie jest pochodną cząstkową), a zielona strzałka pokazuje, w którą stronę się ruszyć, żeby zmniejszyć stratę.

-3.0(b = 3.0 fixed)
3.0(w = -3.0 fixed)

Przeciągaj suwaki i patrz, co się dzieje:

  • Daleko od minimum — krzywa jest stroma, styczna mocno się przechyla, a pochodna jest dużą liczbą. Spadek gradientu stawia tu wielkie kroki.
  • Blisko minimum — krzywa się wypłaszcza, styczna jest prawie pozioma, a pochodna bliska zeru. Kroki robią się maleńkie — model dostraja.
  • W minimum — styczna jest idealnie płaska. Pochodna wynosi zero. Nie ma dokąd iść — dotarłeś.

Zauważ coś ciekawego: gdy przeciągasz w, prawy wykres zmienia kształt — i odwrotnie. Dlaczego?

Lewy wykres pyta: „dla każdego możliwego w, jaka jest strata?” — przy b ustalonym na tym, co mówi suwak. Gdy przeciągasz w, wybierasz jedynie, w którym punkcie tej krzywej stanąć. Sama krzywa się nie zmienia, bo b się nie zmieniło.

Ale prawy wykres pyta: „dla każdego możliwego b, jaka jest strata?” — przy ustalonym w. Gdy przeciągasz w, zmieniasz to ustalone w, którego używa się do policzenia każdego punktu prawej krzywej. Inne w oznacza inne błędy przy każdym b, co oznacza zupełnie inną krzywą. (I odwrotnie — przeciąganie b zmienia kształt lewego wykresu, a na prawym tylko przesuwa kropkę.)

Dlatego liczymy obie pochodne cząstkowe z tych samych błędów przed jakąkolwiek aktualizacją. Najlepszy kierunek pchnięcia w zależy od tego, gdzie aktualnie jest b, i odwrotnie — więc najpierw mierzysz oba nachylenia, a potem ruszasz oba parametry.

Od pochodnej do gradientu

Gradient to po prostu wektor wszystkich pochodnych cząstkowych zebranych razem: [dw, db]. Wskazuje kierunek najszybszego wzrostu straty. Ruszamy się więc w kierunku przeciwnym — dlatego reguła aktualizacji odejmuje: w = w - lr * dw.

Dwa wykresy powyżej to tak naprawdę tylko dwuwymiarowe przekroje powierzchni 3D. Przy dwóch parametrach możemy zwizualizować cały krajobraz straty — w na jednej osi, b na drugiej, a strata jako wysokość. Choć nasz model jest liniowy (y = wx + b), funkcja straty jest kwadratowa — w kształcie miski — bo MSE podnosi błędy do kwadratu. Inne funkcje straty (jak entropia krzyżowa) dają krajobrazy o innych kształtach. Taka miska nazywa się wypukłym krajobrazem straty — jest tylko jedna dolina, więc gdziekolwiek zaczniesz, każdy kierunek w dół prowadzi do tego samego dna. Spróbuj klikać Step (both) z różnych punktów startowych — zawsze wylądujesz przy w ≈ 2, b ≈ 1. Głębokie sieci neuronowe mają bardziej złożone, niewypukłe krajobrazy z wieloma dolinami, ale ten sam algorytm spadku gradientu wciąż działa w praktyce zaskakująco dobrze.

-3.0
3.0

Spróbuj klikać Step (w) i Step (b) osobno — zobaczysz, jak punkt przesuwa się wzdłuż jednej osi naraz, tworząc schodkowy wzór w dół miski. Potem spróbuj Step (both) — tak działa prawdziwy spadek gradientu, aktualizując oba parametry naraz. Powierzchnię możesz obracać przeciąganiem, żeby zobaczyć ją pod różnymi kątami.

Żółta strzałka to wektor gradientu — pokazuje kierunek, w którym pójdzie następny krok. Łączy dw i db w jeden kierunek: „ruszaj się tędy, żeby najszybciej zmniejszyć stratę”. Klikając Step (w) albo Step (b), poruszasz się wzdłuż tylko jednej składowej tego wektora. Klikając Step (both), podążasz za całą strzałką.

Możesz zauważyć, że strzałka wskazuje głównie wzdłuż osi w. To dlatego, że gradienty nie są równe — w punkcie startowym dw = -20, a db = 4. Składowa w jest 5 razy większa, więc dominuje w kierunku. Spadek gradientu nie porusza się jednakowo we wszystkich kierunkach; porusza się proporcjonalnie do tego, jak czuła jest strata na każdy parametr. Strata jest tu znacznie bardziej stroma wzdłuż w, więc w zostaje skorygowane najpierw.

Dlaczego strata jest o tyle bardziej czuła na w niż na b? Odpowiedź tkwi we wzorach na gradient. Porównaj je — dw = 2 * mean(error * x) kontra db = 2 * mean(error). Zwróć uwagę na kluczową różnicę: dw mnoży każdy błąd przez odpowiadającą wartość x, podczas gdy db używa samych błędów. Nasze wartości x mieszczą się w zakresie od -2 do 2, więc gdy model bardzo się myli (duże błędy) i wejścia są duże, iloczyn error * x robi się ogromny. Gradient biasu db uśrednia jedynie same błędy — bez mnożenia przez x — więc jest naturalnie mniejszy.

To ogólna własność, nie specyfika naszego przykładu-zabawki. W każdej sieci neuronowej jedne parametry wpływają na stratę mocniej niż inne. Gradient wychwytuje to automatycznie — parametry o dużych gradientach dostają duże aktualizacje, a te o małych gradientach małe. Każdy parametr jest korygowany proporcjonalnie do tego, ile wniósł do błędu. To właśnie czyni spadek gradientu efektywnym: nie marnuje wysiłku na parametry, które już są bliskie poprawnych.

Poza miską: dlaczego głębokie sieci są niewypukłe

Nasza zabawkowa strata jest idealną miską z konkretnego powodu: model y = wx + b jest liniowy względem swoich parametrów, a podniesienie błędów modelu liniowego do kwadratu zawsze daje stratę kwadratową — a każda funkcja kwadratowa to pojedyncza wypukła dolina. Dołóż choćby jedną warstwę ukrytą z nieliniową aktywacją, a ta gwarancja znika.

Wypukłość psują dwie rzeczy. Pierwsza to zagnieżdżenie. W prawdziwej sieci parametr nie dotyka straty bezpośrednio — siedzi wewnątrz aktywacji, wewnątrz sumy ważonej następnej warstwy, wewnątrz jej aktywacji i tak dalej. Nawet najprostsza sieć z jedną warstwą ukrytą liczy coś w rodzaju

f(xi)=β0+k=1Kβkg ⁣(wk0+j=1pwkjxij),f(x_i) = \beta_0 + \sum_{k=1}^{K} \beta_k \, g\!\left(w_{k0} + \sum_{j=1}^{p} w_{kj} x_{ij}\right),

gdzie każda jednostka ukryta kk owija własną sumę ważoną w nieliniowe gg. Strata 12i(yif(xi))2\frac{1}{2}\sum_i (y_i - f(x_i))^2 jest teraz głęboko złożoną funkcją wag, a funkcje złożone się wyginają — wyrastają im garby i zagłębienia, a nie jedna czysta dolina.

Druga to symetria. Jednostki ukryte są wymienne: zamień jednostkę 1 z jednostką 2 (razem z ich wagami), a sieć policzy dokładnie tę samą funkcję przy dokładnie tej samej stracie. Przy KK jednostkach ukrytych takich przenumerowań jest K!K!, więc każdemu rozwiązaniu towarzyszy tłum identycznych bliźniaków rozsianych po krajobrazie. Strata z wieloma równie dobrymi najniższymi punktami z definicji nie jest pojedynczą miską.

Konsekwencją są minima lokalne — doliny niższe niż wszystko wokół, ale niebędące najniższym punktem w ogóle. Spadek gradientu zawsze idzie tylko w dół od miejsca startu, więc w zasadzie może osiąść w minimum lokalnym i się zatrzymać, znalazłszy jakieś rozwiązanie, ale nie najlepsze, czyli minimum globalne.

W praktyce ma to znacznie mniejsze znaczenie, niż brzmi. W bardzo wysokowymiarowych przestrzeniach, w których żyją prawdziwe sieci, złe minima lokalne są rzadkie — większość punktów, w których gradient znika, okazuje się punktami siodłowymi (w dół w jednych kierunkach, w górę w innych), obok których spadek gradientu się prześlizguje, a te minima lokalne, które istnieją, bywają niemal tak dobre jak globalne. Szum z aktualizacji na mini-batchach (następna sekcja) też potrząsa parametrami na tyle, by wyrwać je z płytkich pułapek. Dlatego ten sam prosty algorytm wciąż działa zaskakująco dobrze w krajobrazie, który na papierze wygląda beznadziejnie. Dwa standardowe nawyki trzymają go w ryzach: powolne uczenie — dopasowywanie stopniowo i zatrzymanie w chwili, gdy strata walidacyjna zaczyna rosnąć — oraz regularyzacja — dodanie kar (jak człon L2/grzbietowy), które ciągną wagi ku zeru i wygładzają krajobraz.

Stochastyczny spadek gradientu

Do tej pory liczyliśmy gradienty przy użyciu wszystkich danych treningowych naraz. Za każdym razem, gdy klikałeś „Step” w widgecie powyżej, dw = 2 * mean(error * x) liczyło 2 * error * x osobno dla każdego z naszych 5 punktów, a potem uśredniało je w jeden gradient:

dw = 2 * mean(error * x)
   = 2 * mean([-24.00, -7.00, 0.00, -3.00, -16.00])
   = -20.00

Przy 5 punktach to trywialne. Zaletą użycia wszystkich danych jest to, że uśredniony gradient wskazuje najlepszy możliwy kierunek — każdy punkt ma swój głos, więc żaden pojedynczy odstający nie zepchnie aktualizacji z kursu. Ale prawdziwe zbiory danych mają miliony albo miliardy przykładów. Liczenie gradientu dla każdego z osobna i uśrednianie ich wszystkich przed jednym krokiem jest drogie. Wyobraź sobie zbiór z miliardem przykładów — musiałbyś przetworzyć cały miliard, zanim choć raz zaktualizujesz w i b. To jeden krok. Potem znowu dla następnego. Każdy krok daje bardzo dokładny gradient, ale między aktualizacjami czekasz w nieskończoność.

Lekarstwo jest proste: nie używaj wszystkich danych naraz. Przetasuj przykłady treningowe, a potem podziel je na małe grupy — mini-batche. Uruchom całą procedurę na pierwszym mini-batchu: policz predykcje, błędy, stratę, gradienty i zaktualizuj parametry — używając tylko tych kilku przykładów. Potem przejdź do następnego mini-batcha i tak dalej.

To jest stochastyczny spadek gradientu (SGD). „Stochastyczny” znaczy po prostu losowy — chodzi o losowe przetasowanie. Reguła aktualizacji jest ta sama, tyle że sumujemy po mini-batchu, a nie po całym zbiorze:

w=wlr1BiBlossiww = w - lr \cdot \frac{1}{|B|} \sum_{i \in B} \frac{\partial \text{loss}_i}{\partial w}

gdzie BB to bieżący mini-batch. Każdy mini-batch daje zaszumione oszacowanie prawdziwego gradientu — nie wskaże dokładnie właściwego kierunku, ale będzie z grubsza poprawny. W ciągu epoki swój wkład wnosi każdy przykład, a szum się uśrednia.

Gdy przejdziesz przez wszystkie przykłady, to jedna epoka. Przetasuj ponownie i zacznij następną. Dlatego widzisz „epokę” w logach treningowych — każda epoka oznacza, że model zobaczył każdy przykład ze zbioru dokładnie raz.

Dla naszych 5 punktów przy rozmiarze batcha 2 wygląda to tak:

EpokaPrzetasowane daneBatch 1Batch 2Batch 3
1[0, 2, -1, -2, 1](0, 2)(-1, -2)(1)
2[2, -2, 1, 0, -1](2, -2)(1, 0)(-1)
3[-1, 1, -2, 2, 0](-1, 1)(-2, 2)(0)

Każdy batch przechodzi pełną 4-krokową pętlę (przejście w przód → strata → propagacja wsteczna → spadek gradientu), więc każda epoka wykonuje 3 aktualizacje parametrów zamiast 1. Do końca każdej epoki każdy punkt danych został użyty dokładnie raz — ale kolejność za każdym razem jest inna, co powstrzymuje model przed zapamiętaniem sekwencji.

Widget poniżej uruchamia obie metody obok siebie na naszym 5-punktowym zbiorze, żebyś mógł je porównać bezpośrednio. Obie startują z tych samych parametrów (w = -3, b = 3) i używają tego samego współczynnika uczenia. Każde kliknięcie „Step” wykonuje jedną aktualizację parametrów dla każdej metody. Niebieska linia (pełny batch) używa wszystkich 5 punktów w każdym kroku. Pomarańczowa linia (mini-batch) używa tylko batch_size punktów — pomarańczowe kółka pokazują, których, a pasek epoki śledzi postęp przez zbiór.

2
0.10

Kliknij „Step” kilka razy i popatrz na krzywe straty po prawej. Niebieska krzywa (pełny batch) opada gładko — każdy krok używa wszystkich danych, więc gradient zawsze wskazuje najlepszy kierunek. Pomarańczowa krzywa (mini-batch) zygzakuje.

Domyślnie włączona jest stała kolejność, więc batche są takie same przy każdym uruchomieniu, co czyni ten zygzak powtarzalnym. Odznacz ją, żeby tasować losowo w każdej epoce — pomarańczowa krzywa będzie za każdym razem inna, ale ogólne zachowanie pozostaje takie samo.

Przeklikaj kilka pierwszych kroków, żeby zobaczyć dlaczego: krok 1→2 strata spada (batch akurat dał dobry gradient), ale krok 2→3 strata rośnie — tamten batch pociągnął parametry w kierunku, który pomógł jego własnym punktom, ale zaszkodził innym. Potem krok 3→4 znów spada. To normalne: każdy batch widzi tylko wycinek danych, więc niektóre kroki przestrzeliwują albo wręcz idą w złą stronę. Przez wiele kroków te błędy się uśredniają i model i tak zbiega.

Pełny batch zbiega w mniejszej liczbie kroków, bo każdy krok używa wszystkich danych — po co więc zawracać sobie głowę mini-batchami? Porównaj koszt 3 aktualizacji parametrów przy naszych 5 punktach:

  • Pełny batch (3 kroki): każdy krok używa wszystkich 5 punktów. To 3 × 5 = 15 przetworzeń punktów. Każdy punkt jest przetwarzany 3 razy.
  • SGD z batchem 2 (3 kroki = 1 epoka): każdy krok używa tylko 2 punktów (albo 1 dla ostatniego batcha). To 2 + 2 + 1 = 5 przetworzeń punktów. Każdy punkt jest przetwarzany raz.

Obie metody robią 3 aktualizacje parametrów, ale SGD robi to przy 3× mniejszej liczbie obliczeń. Aktualizacje są bardziej zaszumione, ale oszczędności w skali są ogromne — przy miliardzie przykładów i batchu 1000 jedna epoka daje milion aktualizacji, przetwarzając każdy przykład tylko raz. Pełny batch musiałby przetworzyć wszystkie miliard przykładów dla każdej z tych aktualizacji. Widget nie pokaże tej różnicy kosztów (oba to tylko kliknięcie), ale w prawdziwej skali mini-batch dochodzi do odpowiedzi szybciej w czasie zegarowym, mimo że potrzebuje więcej kroków.

Możesz też wypróbować różne rozmiary batcha, żeby zobaczyć różnicę w zachowaniu:

  • rozmiar batcha = 1 — maksimum szumu, każdy krok używa jednego punktu. Krzywa straty zygzakuje dziko, ale wciąż zbiega. 5 kroków = 1 epoka (każdy punkt widziany raz). To oryginalny „stochastyczny” spadek gradientu.
  • rozmiar batcha = 2 — mniej szumu, każda epoka zabiera 3 kroki (2 + 2 + 1 na koniec). To bliższe temu, czego używa się w praktyce.
  • rozmiar batcha = 5 — to wszystkie nasze dane w jednym batchu, więc jest identyczne z pełnobatchowym spadkiem gradientu. Obie linie nakładają się idealnie. 1 krok = 1 epoka.

W praktyce częste są rozmiary batcha 32, 64 albo 256. Kompromis: mniejsze batche oznaczają więcej kroków na epokę (bardziej zaszumione, ale szybsze na krok); większe batche oznaczają mniej kroków (gładsze, ale więcej obliczeń na krok). Szum z małych batchy może wręcz pomagać — powstrzymuje model przed utknięciem w płytkich minimach lokalnych.

Reguła łańcuchowa przez wiele warstw

Pamiętasz, jak propagacja wsteczna używa reguły łańcuchowej, mnożąc pochodne lokalne przez siebie? Nasz przykład był najprostszym przypadkiem: pojedynczy neuron z jedną wagą w, jednym biasem b i jednym wejściem:

wbxnŷerrlossinput1 neuronoutput∂loss/∂w: x · 1 · 2·error ∂loss/∂b: 1 · 1 · 2·error

Łańcuch od każdego parametru do straty miał 3 ogniwa (f1f2f3)(f'_1 \cdot f'_2 \cdot f'_3), co w Pythonie wyglądało tak:

y_pred = w * x + b                  # f1: prediction
error  = y_pred - y                 # f2: how far off
loss   = error ** 2                 # f3: squared error

dw = x * 1 * (2 * error)           # chain rule for w: f'1 · f'2 · f'3
db = 1 * 1 * (2 * error)           # chain rule for b: same chain, different f'1

Ale prawdziwe sieci mają wiele neuronów na warstwę, każdy z własnymi wagami. Obliczanie gradientu się nie zmienia — wciąż liczymy pochodną cząstkową dla każdej pojedynczej wagi regułą łańcuchową, tak jak wcześniej. Różnicą jest skala. Łańcuch robi się dłuższy dla wag siedzących dalej od straty — więcej warstw do przejścia, więcej pochodnych do przemnożenia. I robi się szerszy — wyjście neuronu może zasilać wiele neuronów w następnej warstwie, więc gradient musi zsumować wkłady ze wszystkich tych ścieżek.

Spójrz na tę sieć z 2 warstwami. Przełączaj się między dwoma przyciskami, żeby zobaczyć, jak ścieżka gradientu różni się zależnie od tego, gdzie siedzi waga:

w₁v₁x₁x₂h₁h₂h₃g₁g₂g₃ŷlossinputlayer 1layer 2output

Gradient dla v₁ (warstwa 2) jest aktywny na start — v₁ to waga na połączeniu z h₁ do g₁. Jego łańcuch gradientu jest krótki: tylko to jedno połączenie, potem g₁→ŷ→strata. Zauważ, że podświetla się wyłącznie połączenie h₁→g₁, a nie h₂→g₁ czy h₃→g₁. Dlaczego? Bo gdy liczymy pochodną cząstkową względem v₁, pozostałe wejścia do g₁ są trzymane na stałe — mnożą się przez inne wagi i nie pojawiają się w pochodnej v₁. To ta sama zasada co w modelu z jednym neuronem: pochodna w·x + b względem w to po prostu x — drugi parametr b się nie pojawia. Tak samo tutaj: pochodna v₁·h₁ + v₂·h₂ + v₃·h₃ względem v₁ to po prostu h₁. Zatem gradient wynosi:

lossv1=h1pochodna lokalnalossg1od wyjsˊcia → do straty\frac{\partial \text{loss}}{\partial v_1} = \underbrace{h_1}_{\text{pochodna lokalna}} \cdot \underbrace{\frac{\partial \text{loss}}{\partial g_1}}_{\text{od wyjścia → do straty}}

Inny sposób spojrzenia na ten wzór to rozwinięcie lossg1\frac{\partial \text{loss}}{\partial g_1} — skoro strata to po prostu error², a błąd płynie prosto z wyjścia, sprowadza się to do 2 · error:

lossv1=h1pochodna lokalnalossg1od wyjsˊcia → do straty=h1wejsˊcie2errorod straty\frac{\partial \text{loss}}{\partial v_1} = \underbrace{h_1}_{\text{pochodna lokalna}} \cdot \underbrace{\frac{\partial \text{loss}}{\partial g_1}}_{\text{od wyjścia → do straty}} = \underbrace{h_1}_{\text{wejście}} \cdot \underbrace{2 \cdot error}_{\text{od straty}}

Teraz kliknij Gradient for w₁ (layer 1) — w₁ to waga na połączeniu x₁→h₁. Łańcuch zaczyna się tak samo (jedno połączenie), ale potem wyjście h₁ zasila każdy neuron warstwy 2 — g₁, g₂ i g₃. Zmiana w₁ rozchodzi się przez nie wszystkie, zanim dotrze do wyjścia i straty. Gradient musi zsumować wkłady ze wszystkich trzech ścieżek:

lossw1=x1relu(z1)(v1lossg1+v2lossg2+v3lossg3)suma po wszystkich sˊciez˙kach przez warstwę 2\frac{\partial \text{loss}}{\partial w_1} = x_1 \cdot \text{relu}'(z_1) \cdot \underbrace{\left(v_1 \cdot \frac{\partial \text{loss}}{\partial g_1} + v_2 \cdot \frac{\partial \text{loss}}{\partial g_2} + v_3 \cdot \frac{\partial \text{loss}}{\partial g_3}\right)}_{\text{suma po wszystkich ścieżkach przez warstwę 2}}

Rozwijając każde lossgi\frac{\partial \text{loss}}{\partial g_i} do 2 · error (tak jak wcześniej — strata to po prostu error²):

=x1relu(z1)(v12error+v22error+v32error)= x_1 \cdot \text{relu}'(z_1) \cdot \left(v_1 \cdot 2 \cdot error + v_2 \cdot 2 \cdot error + v_3 \cdot 2 \cdot error\right)

Łańcuch jest więc nie tylko dłuższy (więcej warstw do przejścia), ale też szerszy (więcej ścieżek do zsumowania na każdej warstwie). Głębsze sieci oznaczają dłuższe łańcuchy; szersze warstwy oznaczają więcej ścieżek na łańcuch.

To ta sama zasada reguły łańcuchowej, której użyliśmy przy f1f2f3f'_1 \cdot f'_2 \cdot f'_3. Neuron wyjściowy liczy ŷ = v₁·h₁ + v₂·h₂ + b. Gdy pytamy „jak zmieni się ŷ, jeśli zmieni się h₁?”, pochodną lokalną jest v₁ — dokładnie jak w naszym modelu z jednym neuronem, gdzie pochodna w·x + b względem x wynosiła w. Zatem v₁ pojawia się nie jako optymalizowany parametr, lecz jako pochodna lokalna funkcji łączącej h₁ z ŷ. Każde ogniwo łańcucha wnosi swoją pochodną lokalną, a my mnożymy je wszystkie — jedna z tych pochodnych po prostu akurat jest wagą innej warstwy. To kluczowa różnica względem naszego modelu jednowarstwowego: gradienty wcześniejszych warstw muszą przejść przez wagi wszystkich późniejszych warstw.

Gdy mamy już wszystkie gradienty, aktualizacja jest taka sama jak wcześniej — odejmij lr × gradient od każdej wagi:

# Output layer (short chain)
W2 = W2 - lr * dW2          # 2 weights

# Hidden layer (long chain — gradients passed through W2)
W1 = W1 - lr * dW1          # 4 weights (2×2 matrix)

Oto obliczenie gradientu dla wagi w każdej warstwie, obok siebie:

# Gradient for v₁ (output layer) — short chain
dv1 = h1 * 2 * error
#     ↑    ↑
#     │    └── from loss
#     └── local derivative: ∂ŷ/∂v₁ = h₁

# Gradient for w₁₁ (hidden layer) — longer chain
dw11 = x1 * relu_deriv(z1) * v1 * 2 * error
#      ↑    ↑                 ↑    ↑
#      │    │                 │    └── from loss
#      │    │                 └── passes through output layer weight
#      │    └── activation derivative
#      └── local derivative: ∂z₁/∂w₁₁ = x₁

Waga warstwy wyjściowej v₁ ma w swoim łańcuchu 2 czynniki. Waga warstwy ukrytej w₁₁ ma ich 4 — musi przejść przez funkcję aktywacji i przez wagę warstwy wyjściowej v₁, żeby dotrzeć do straty. Każda dołożona warstwa wydłuża łańcuch każdej wcześniejszej warstwy o jeszcze jedno mnożenie. Przy 100 warstwach gradient pierwszej warstwy jest iloczynem ponad 100 czynników. Co się dzieje, gdy pomnożysz przez siebie tyle liczb?

Problem zanikającego gradientu

Jeśli pochodna lokalna każdej warstwy wynosi 0,5, to po 100 warstwach gradient wynosi 0.51000.5^{100} — liczbę tak małą, że praktycznie zerową. Pierwsze warstwy nie dostają żadnego użytecznego sygnału gradientu. Nie uczą się. To problem zanikającego gradientu, który przez dekady trapił głębokie sieci.

Odwrotność jest równie zła: jeśli pochodne lokalne są większe od 1, gradient eksploduje — rośnie tak bardzo, że aktualizacje stają się dziko niestabilne.

Widget poniżej pozwala zobaczyć to w działaniu. Zejdź z pochodną lokalną poniżej 1 i popatrz, jak gradient gaśnie do zera, płynąc wstecz przez warstwy. Potem spróbuj wartości powyżej 1 i popatrz, jak eksploduje:

8
0.50

Dlatego uczenie głębokie długo tkwiło w miejscu — przy aktywacjach sigmoidalnych (których pochodne są zawsze < 1) gradienty zanikały w sieciach głębszych niż kilka warstw. Przełomy, które to naprawiły, to m.in.:

  • Aktywacja ReLU — jej pochodna wynosi 0 albo 1, więc gradienty nie kurczą się, przechodząc przez nią
  • Połączenia rezydualne (skip connections) — dają gradientowi skrót omijający warstwy, więc nie musi mnożyć się przez każdą z osobna
  • Normalizacja batchowa — utrzymuje wartości płynące przez każdą warstwę w rozsądnym zakresie, zapobiegając systematycznemu kurczeniu się albo rośnięciu pochodnych

To wszystko są sposoby na utrzymanie pochodnych lokalnych blisko 1, żeby iloczyn z reguły łańcuchowej nie zanikał ani nie eksplodował nawet przy setkach warstw.

Gotowy, żeby zastosować to wszystko w praktyce? W następnym artykule budujemy i trenujemy prawdziwą sieć neuronową na MNIST — pisząc przejście w przód, propagację wsteczną i spadek gradientu od zera w NumPy, a potem porównując to z implementacją w Kerasie.