Jak uczą się sieci neuronowe: propagacja wsteczna i spadek gradientu

Sieć neuronowa wykorzystuje parametry, w tym wagi i biasy, do przekształcania wejść w predykcje. Architektura określa wykonywane obliczenia, a wartości parametrów decydują o tym, jak obliczenia te reagują na dane wejściowe. Trening dostraja te wartości na podstawie przykładów, dzięki czemu sieć może nauczyć się rozpoznawać odręczne cyfry lub tłumaczyć tekst.

Trening polega na znajdowaniu użytecznych wartości parametrów. Sieć wykonuje predykcje, mierzy ich błąd funkcją straty i oblicza, jak strata zmienia się względem każdego parametru. Propagacja wsteczna wyznacza te pochodne, nazywane gradientami. Optymalizator, taki jak spadek gradientu, wykorzystuje je do aktualizacji parametrów.

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 wykonuje jedną iterację treningu: oblicza predykcje, mierzy ich błędy (czerwone linie przerywane), wyznacza gradienty i aktualizuje parametry. Przy zastosowanym tutaj współczynniku uczenia prosta z każdym krokiem zbliża się do danych.

Przejdziemy przez te etapy, zaczynając od jednego neuronu i rozszerzając obliczenia na wiele warstw. Widgety pokazują zmiany wartości, a fragmenty kodu w Pythonie łączą je z implementacją.

Zacznij od przykładów neuronu i funkcji straty, a potem przejdź do pochodnych i reguły łańcuchowej. Jeśli znasz już rachunek różniczkowy, możesz przejść od razu do reguły łańcuchowej przez wiele warstw.

Sieć neuronowa to po prostu parametry

Użyjemy gęstej sieci jednokierunkowej: neurony są ułożone w warstwy, a każdy neuron otrzymuje wyjścia poprzedniej warstwy. Każdy z nich łączy swoje wejścia w jedno wyjście. Żeby zrozumieć taką sieć, zaczniemy od pojedynczego neuronu.

Widget poniżej przedstawia neuron z 3 wejściami. Po lewej ustawiasz wartości wejściowe, a po prawej wagi, bias i funkcję aktywacji. Wagi i bias są parametrami uczonymi podczas treningu; funkcję aktywacji wybieramy przy projektowaniu sieci.

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.

Zapiszmy to równaniem:

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

Tutaj xix_i oznacza wejście, wiw_i jego wagę, bb bias, a ff funkcję aktywacji.

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

  • Waga decyduje o wpływie wejścia. Ustaw x₁ = 1.0, a pozostałe wejścia na 0. Przesuwaj w₁ i obserwuj wyjście. Gdy w₁ = 0, to wejście nie ma wpływu niezależnie od swojej wartości.
  • Znak wagi zmienia jej wkład. Ustaw x₁ = 1.0, w₁ = -1.5, a resztę na 0. Suma ważona jest ujemna, więc aktywacja perceptronowa zwraca 0. Teraz ustaw x₁ = -1.0: iloczyn staje się dodatni. Ujemna waga zmniejsza sumę dla dodatniego wejścia, a zwiększa ją dla ujemnego.
  • Bias przesuwa granicę decyzyjną. Gdy wszystkie wejścia wynoszą 0, sumę określa sam bias. Przy aktywacji perceptronowej dodatni bias daje 1; przy ujemnym suma ważona wejść musi go przezwyciężyć, aby wyjście wyniosło 1.
  • Funkcja aktywacji kształtuje wyjście. Przełącz Perceptron (twarde 0/1) na Sigmoid: wyjście staje się gładką wartością między 0 a 1. ReLU przepuszcza wartości dodatnie bez zmian, a ujemne obcina do zera.

Dlaczego funkcje aktywacji mają znaczenie? Bez nieliniowych aktywacji każda warstwa oblicza funkcję afiniczną: przekształcenie liniowe z dodanym biasem. Złożenie takich warstw nadal daje jedną funkcję afiniczną, niezależnie od głębokości sieci. Nieliniowe aktywacje pozwalają reprezentować bardziej złożone zależności. Mogą na przykład przekształcić dane tak, aby klasy, których nie da się rozdzielić prostą, stały się rozdzielalne. Chris Olah ilustruje tę geometrię w 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(w⋅x+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 w⋅x\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

Każdy neuron przechowuje jedną wagę na wejście i bias, a następnie stosuje aktywację:

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(w⋅x+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 output↓stack 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

Geometryczne spojrzenie na te obliczenia znajdziesz w artykule co właściwie robi macierz wag. Śledzi on cztery punkty wejściowe przechodzące przez warstwę ukrytą i pokazuje, jak macierz wag, bias i nieliniowa aktywacja zmieniają ich reprezentację, umożliwiając rozdzielenie klas.

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

Operacje macierzowe pozwalają bibliotekom obliczać wyniki wielu neuronów i wielu przykładów jednocześnie, co dobrze nadaje się do równoległego wykonywania na GPU.

Każda waga i każdy bias to parametr. Pierwsza warstwa ma ich 9, druga 12, a wyjściowa 4: razem 25 parametrów. Trening musi wyznaczyć aktualizację dla każdego z nich.

Jak sieć się uczy?

Trenowanie sieci oznacza dostrajanie wag i biasów. Wagi zwykle inicjalizujemy losowo, aby przełamać symetrię między neuronami. Biasy mogą zaczynać od zera, tak jak w naszym kodzie. Następnie trening aktualizuje te wartości na podstawie gradientów straty.

Każda iteracja treningu ma cztery etapy:

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ź wstecz przez sieć, używając reguły łańcuchowej, aby obliczyć, jak strata zmienia się względem każdego parametru
4. Spadek gradientuOdejmij od każdego parametru jego gradient pomnożony przez współczynnik uczenia, aby wykonać krok w stronę mniejszej straty

Trening obejmuje wszystkie cztery etapy, zaczynając od przejścia w przód. Podczas inferencji sieć używa bieżących parametrów do predykcji; nie oblicza gradientów treningowych ani nie aktualizuje wag.

Dalsza część artykułu skupia się na mierzeniu błędu, obliczaniu gradientów i używaniu ich do aktualizacji parametrów.

Proces ma dwa kierunki: dane płyną w przód, aby powstała predykcja, a gradienty wstecz, aby optymalizator mógł 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

Zaczniemy od jednego neuronu, jednego wejścia, jednej wagi i jednego biasu. Dzięki temu możemy zbadać stratę zależną od tylko dwóch parametrów, zanim rozszerzymy obliczanie gradientów na wiele warstw.

Przy jednym wejściu i aktywacji tożsamościowej, która zwraca wejście bez zmian, neuron oblicza y^=wx+b\hat y = wx + b. Tutaj ww i xx są skalarami. Dopasowanie prostej pozwala zbadać algorytm uczenia bez komplikowania obliczeń pochodną aktywacji.

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

Dane dostarczają wejścia i wyjścia. Musimy znaleźć łączące je parametry: ww, czyli nachylenie prostej, oraz bb, czyli punkt przecięcia z osią y.

Uczenie funkcji pozwala przewidywać wyniki dla wejść nieobecnych w danych treningowych. Jeśli zależność ma postać f(x)=2x+1f(x) = 2x + 1, możemy przewidzieć f(1.5)=4f(1.5) = 4, mimo że 1.5 nie ma w tabeli. Trafne predykcje dla nowych wejść nazywamy generalizacją. Nasze dane leżą dokładnie na prostej; przy rzeczywistych danych samo dobre dopasowanie treningowe nie dowodzi generalizacji.

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

Przy suwakach widzimy stratę: jedną liczbę podsumowującą błędy predykcji w całym zbiorze.

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=prediction−actualerror = prediction - actual.

Różne funkcje straty podsumowują te błędy na różne sposoby:

  • 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ą, czyli wykonujemy regresję (przewidujemy liczbę ciągłą), używamy błędu średniokwadratowego, MSE: podnosimy każdy błąd do kwadratu i obliczamy średnią. Kwadraty są nieujemne, więc błędy dodatnie i ujemne nie znoszą się, a większe błędy mają większy wpływ:

MSE=1n∑i=1n(ypredi−yactuali)2=(ypred1−yactual1)2+(ypred2−yactual2)2+⋯+(ypredn−yactualn)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

MSE wynosi 2.0. Jego pierwiastek, RMSE, wynosi 2≈1.41\sqrt{2} \approx 1.41 i ma te same jednostki co predykcje. To inna miara niż średni błąd bezwzględny, który tutaj wynosi (2+1+0+1+2)/5=1.2(2 + 1 + 0 + 1 + 2)/5 = 1.2. Przy w=2w = 2 i b=1b = 1 wszystkie błędy i obie miary wynoszą zero.

Dwa parametry udało nam się dobrać ręcznie. Przy milionach parametrów taka metoda staje się niepraktyczna. Później zastosujemy te obliczenia do klasyfikowania odręcznych cyfr siecią mającą ponad 100 000 parametrów. Potrzebujemy systematycznej metody: propagacja wsteczna wyznacza gradienty, a spadek gradientu wykorzystuje je do wykonywania małych aktualizacji. Powtarzamy te kroki, aby zmniejszać stratę.

Liniowe zadanie najmniejszych kwadratów można rozwiązać bezpośrednio za pomocą równań normalnych lub rozkładu macierzy. Otrzymujemy minimum sumy kwadratów błędów, co nie gwarantuje idealnego dopasowania do dowolnych danych. Ogólne sieci neuronowe nie mają podobnego rozwiązania bezpośredniego, więc używamy optymalizatorów iteracyjnych, takich jak spadek gradientu.

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: zielone punkty danych i niebieska prosta predykcji dla aktualnych ww i bb. Czerwone linie pokazują błędy, których kwadraty uśredniamy, otrzymując MSE.
  • Prawy wykres: strata po każdej aktualizacji. Przy domyślnym współczynniku uczenia maleje do zera. Wypłaszczenie może oznaczać zbieżność; wzrost straty może wskazywać na zbyt duży współczynnik uczenia.

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)

Zostaw domyślny współczynnik uczenia 0.1 i klikaj Step. Strata maleje, a prosta zbliża się do danych. Rozwiń Computation, aby obejrzeć predykcje, błędy i gradienty.

W tym przykładzie gradienty maleją, gdy parametry zbliżają się do minimum. Aktualizacje lr * dw i lr * db również maleją, choć lr pozostaje stałe. Parametry zbiegają do w = 2, b = 1. Większy współczynnik uczenia może uniemożliwić zbieżność, co pokaże następny widget.

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

Etapy 1 i 2 wyznaczają predykcje i ich MSE. Etap 3 oblicza gradienty straty. Etap 4 odejmuje od każdego parametru jego gradient pomnożony przez współczynnik uczenia lr. Najpierw przyjrzymy się temu współczynnikowi, a potem wyprowadzimy dw i db.

Dobór 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.

Dla różniczkowalnej funkcji straty z niezerowym gradientem ujemny gradient wskazuje lokalny kierunek spadku. Skończony krok może jednak przekroczyć minimum i zwiększyć stratę. W naszym przykładzie:

  • Powolna zbieżność (0.01): małe aktualizacje wymagają wielu kroków, aby zbliżyć się do minimum.
  • Regularna zbieżność (0.1): strata maleje w każdym kroku.
  • Gasnące oscylacje (0.4): w przekracza optimum w każdym kroku, ale odległość od niego maleje.
  • Stałe oscylacje (0.5): w przeskakuje między -3 a 7. Bias od razu osiąga 1, ale po pierwszym kroku strata pozostaje równa 50.
  • Rozbieżność (1.0 lub 1.5): w coraz bardziej przekracza optimum, a strata rośnie.

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

0.10

Stabilny zakres współczynnika uczenia zależy od funkcji straty. Dla tych danych zbieżność z dowolnego punktu początkowego wymaga dodatniej wartości mniejszej od 0.5. Zobaczymy dlaczego, gdy uprościmy wzór na stratę.

Większe sieci wymagają dobrania współczynnika uczenia do modelu, danych i optymalizatora. Harmonogram współczynnika uczenia zmienia go w czasie treningu. Optymalizatory adaptacyjne, takie jak Adam, wykorzystują też historię gradientów do skalowania aktualizacji. Zmienia to regułę aktualizacji, zachowując przejście w przód, obliczanie straty i propagację wsteczną.

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

Przesuń w: punkt porusza się po lewej krzywej, a prawa przesuwa się pionowo. Zmiana b działa odwrotnie. Dla tego wycentrowanego zbioru minimum względem w pozostaje przy 2, a względem b przy 1. Parametry można tutaj optymalizować niezależnie; poniżej wyprowadzimy przyczynę.

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.25−4=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

Pochodna to granica tego ilorazu, gdy zmiana wejścia dąży do zera:

f′(x)=lim⁡dx→0f(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ęść lim⁡dx→0\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+2x⋅dx+dx2f(x + dx) = (x + dx)^2 = x^2 + 2x \cdot dx + dx^2 f(x+dx)−f(x)=2x⋅dx+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 dx→0dx \to 0, zostaje po prostu 2x2x. Czyli dfdx=2x\frac{df}{dx} = 2x.

Mając wzór na pochodną, możemy bezpośrednio obliczać 2x2x, zamiast szacować nachylenie za pomocą coraz mniejszych zmian wejścia.

Inne wizualne wprowadzenie znajdziesz w The Essence of Calculus autorstwa 3Blue1Brown.

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

Aby wyprowadzić gradient, rozważmy najpierw jeden punkt danych. Jego błąd kwadratowy zależy od w przez trzy operacje: predykcję, odjęcie wartości docelowej i podniesienie do kwadratu. Uśrednimy wyniki po zbiorze dopiero po zróżniczkowaniu tego łańcucha.

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:

w→f1y_pred→f2error→f3error2w \xrightarrow{f_1} y\_pred \xrightarrow{f_2} error \xrightarrow{f_3} error^2

Rozpisując:

  • f1(w)=w⋅x+bf_1(w) = w \cdot x + b — predykcja modelu
  • f2(y_pred)=y_pred−yf_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=f1′⋅f2′⋅f3′\frac{d(\text{loss})}{dw} = f'_1 \cdot f'_2 \cdot f'_3

Przypomnij sobie wzór na pochodną:

f′(x)=lim⁡dx→0f(x+dx)−f(x)dxf'(x) = \lim_{dx \to 0} \frac{f(x + dx) - f(x)}{dx}.

Iloraz skończonych zmian zbliża się w granicy do pochodnej. Zapisujemy ją jako df/dxdf/dx, z wielkością wyjściową u góry i wejściową na dole. Dla trzech funkcji w naszym łańcuchu:

  • f1′f'_1: wyjściem jest y_predy\_pred, wejściem ww → d(y_pred)dw\frac{d(y\_pred)}{dw}
  • f2′f'_2: wyjściem jest errorerror, wejściem y_predy\_pred → d(error)d(y_pred)\frac{d(\text{error})}{d(y\_pred)}
  • f3′f'_3: wyjściem jest error2error^2, wejściem errorerror → d(error2)d(error)\frac{d(\text{error}^2)}{d(\text{error})}

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

d(loss)dw=f1′⋅f2′⋅f3′=d(y_pred)dw⋅d(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? Wyjście każdej funkcji staje się wejściem następnej. Mała zmiana w zmienia y_pred o x razy tę zmianę. Następnie error zmienia się o tę samą wartość. Przy dostatecznie małej zmianie error² zmienia się w przybliżeniu o 2·error razy zmianę error. Każde ogniwo skaluje zmianę, a te skale mnożą się przez siebie.

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 g′g', a potem ta przeskalowana zmiana jeszcze raz przez f′f'.

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 x−cx - 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=w⋅x+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 x−cx - c to 11. Skoro error=y_pred−yerror = 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=f1′⋅f2′⋅f3′=x⋅1⋅(2⋅error)=2⋅error⋅x\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 2⋅error⋅x2 \cdot error \cdot x dla pojedynczego punktu danych.

Prześledźmy to na 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 daje f1′⋅f2′⋅f3′=2⋅1⋅4=8f'_1 \cdot f'_2 \cdot f'_3 = 2 \cdot 1 \cdot 4 = 8. Dla małej zmiany Δw\Delta w błąd kwadratowy zmienia się w przybliżeniu o 8Δw8\Delta w. To przybliżenie lokalne: zwiększenie w z 3 do 4 zmienia błąd kwadratowy z 4 na 16, czyli o 12, a nie 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 2⋅error⋅x2 \cdot error \cdot x:

xxyyypred=3x+1y_{pred} = 3x + 1errorerror2⋅error⋅x2 \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
01102⋅0⋅0=02 \cdot 0 \cdot 0 = 0
13412⋅1⋅1=22 \cdot 1 \cdot 1 = 2
25722⋅2⋅2=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=1n∑i=1n2⋅errori⋅xi=2⋅1n∑i=1nerrori⋅xi\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 f1′f'_1 jest inne: skoro y_pred=w⋅x+by\_pred = w \cdot x + b, pochodna względem b to po prostu 1 (zamiast x). Czyli:

d(loss)db=f1′⋅f2′⋅f3′=1⋅1⋅(2⋅error)=2⋅error\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)=1n∑i=1n(w⋅xi+b−yi)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 przedstawia w, a oś y stratę. Minimum występuje przy w = 2, ale strata wynosi wtedy 4, ponieważ b nadal jest równe 3. Każda predykcja przewyższa cel o 2. Przesuwaj suwak i sprawdzaj predykcje, błędy oraz ich kwadraty składające się na każdy punkt krzywej.

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.

Te wykresy obliczają stratę dla wielu wartości parametrów, aby pokazać jej kształt. Sprawdzenie wszystkich kombinacji staje się niepraktyczne wraz ze wzrostem liczby parametrów. Spadek gradientu oblicza pochodne w bieżącym punkcie i wykorzystuje je do wyboru następnego kroku.

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ś.

Dla naszych pięciu punktów yi=2xi+1y_i = 2x_i + 1, średnia xx wynosi zero, a średnia x2x^2 wynosi 2. Rozwinięcie straty daje:

L(w,b)=15∑i((w−2)xi+(b−1))2=2(w−2)2+(b−1)2L(w,b) = \frac{1}{5}\sum_i\big((w-2)x_i + (b-1)\big)^2 = 2(w-2)^2 + (b-1)^2

Wyraz mieszany znika, ponieważ suma wejść wynosi zero. Dlatego zmiana w przesuwa krzywą dla b pionowo, nie zmieniając położenia jej minimum, i odwrotnie. Pochodne upraszczają się do:

∂L∂w=4(w−2),∂L∂b=2(b−1)\frac{\partial L}{\partial w} = 4(w-2), \qquad \frac{\partial L}{\partial b} = 2(b-1)

Po jednej aktualizacji spadkiem gradientu odległości od optimum wynoszą:

wnew−2=(1−4lr)(w−2),bnew−1=(1−2lr)(b−1)w_{\text{new}}-2 = (1-4lr)(w-2), \qquad b_{\text{new}}-1 = (1-2lr)(b-1)

Obie odległości maleją, gdy 0<lr<0.50 < lr < 0.5. Przy lr = 0.5 pierwszy mnożnik wynosi -1, więc w oscyluje bez zbliżania się do optimum. Przy lr = 1 mnożnik wynosi -3, więc odległość potraja się w każdym kroku.

Ta niezależność jest własnością naszego wycentrowanego przykładu liniowego. Dla innych danych i sieci wielowarstwowych gradient jednego parametru zwykle zależy od pozostałych. Aby wykonać krok spadku gradientu, najpierw oblicz wszystkie gradienty dla bieżących parametrów, a potem zastosuj aktualizacje.

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.

Przy dwóch parametrach możemy narysować stratę jako powierzchnię 3D: w na jednej osi, b na drugiej, strata jako wysokość. Nasza strata tworzy wypukłą miskę z jednym minimum przy w = 2, b = 1. Wcześniejsze krzywe to przekroje tej powierzchni. Wypróbuj Step (both) z różnych punktów; przy stałym współczynniku uczenia widgetu 0.1 parametry zbliżają się do tego samego minimum.

-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 pokazuje kierunek aktualizacji: jej składowe w osiach parametrów są proporcjonalne do [-dw, -db], czyli ujemnego gradientu. Wysokość odzwierciedla zmianę straty. Step (w) i Step (b) przesuwają wzdłuż jednej osi, a Step (both) łączy obie aktualizacje.

W punkcie początkowym w = -3, b = 3 gradienty wynoszą dw = -20 i db = 4, więc aktualizacja przesuwa dalej wzdłuż osi w. Ich względne wielkości zależą od krzywizny straty i bieżących parametrów. Przy w = 2, b = 3 mamy na przykład dw = 0 i zmienia się tylko bias.

Gradient mierzy lokalną wrażliwość, a nie udział parametru w odpowiedzialności za błąd. Mały gradient może wystąpić w pobliżu minimum, ale również w płaskim obszarze o dużej stracie.

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

Nasza strata kwadratowa jest wypukła, ponieważ stanowi sumę kwadratów wyrażeń afinicznych względem parametrów. Dla tych danych L=2(w−2)2+(b−1)2L = 2(w-2)^2 + (b-1)^2 ma jedno minimum. Ogólna funkcja kwadratowa nie musi być wypukła: na przykład −w2-w^2 wygina się w dół.

W sieci wielowarstwowej parametry kolejnych warstw oddziałują przez iloczyny i aktywacje, więc strata jest zwykle niewypukła. Wystarczą nawet dwie skalarne warstwy liniowe. Niech ich wagi wynoszą aa i cc, wejście 1, a cel 1:

y^=ca,L(a,c)=(ca−1)2\hat y = ca, \qquad L(a,c) = (ca-1)^2

Zarówno (a,c)=(1,1)(a,c)=(1,1), jak i (−1,−1)(-1,-1) dają zerową stratę. W ich środku, (0,0)(0,0), strata wynosi 1. Dla funkcji wypukłej wartość w środku nie może przewyższać średniej wartości na końcach, więc ta strata jest niewypukła. Nieliniowe aktywacje dodają kolejne zależności, ale nie są niezbędne do niewypukłości względem parametrów.

Takie powierzchnie mogą zawierać minima lokalne, punkty siodłowe i płaskie obszary. Zerowy gradient nie dowodzi znalezienia najlepszego rozwiązania, a spadek gradientu nie gwarantuje minimum globalnego. Więcej opisuje rozdział o optymalizacji w podręczniku Deep Learning.

Celem treningu są parametry dające użyteczne predykcje na nowych danych. Oprócz straty treningowej monitorujemy wyniki walidacji. Wczesne zatrzymanie i regularyzacja mogą ograniczać przeuczenie; nie zmieniają jednak niewypukłej straty w wypukłą.

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

Użycie wszystkich 5 punktów daje dokładny gradient straty treningowej. Nie zapewnia odporności na obserwacje odstające: duży błąd nadal może zdominować gradient MSE. Przy dużym zbiorze przetwarzanie każdego przykładu przed każdą aktualizacją jest kosztowne. Minibatche pozwalają aktualizować parametry po przetworzeniu mniejszej części danych.

Przetasuj przykłady i podziel je na minibatche. Dla każdego wykonaj 4 etapy treningu: przejście w przód, obliczenie straty, propagację wsteczną i aktualizację parametrów. Każda aktualizacja wykorzystuje tylko przykłady z danego batcha.

To stochastyczny spadek gradientu (SGD). „Stochastyczny” oznacza losowy i odnosi się tu do tasowania. Reguła aktualizacji pozostaje ta sama, ale uśredniamy po minibatchu zamiast po całym zbiorze:

w=w−lr⋅1∣B∣∑i∈B∂lossi∂ww = w - lr \cdot \frac{1}{|B|} \sum_{i \in B} \frac{\partial \text{loss}_i}{\partial w}

BB oznacza bieżący minibatch. Jego gradient szacuje gradient pełnego zbioru dla aktualnych parametrów. Pojedyncza aktualizacja może zwiększyć stratę na całym zbiorze. Ponieważ parametry zmieniają się między batchami, jedna epoka takich aktualizacji nie jest równoważna jednej aktualizacji pełnym batchem.

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 wykonuje pełną pętlę (przejście w przód → strata → propagacja wsteczna → spadek gradientu), więc epoka daje 3 aktualizacje zamiast 1. Każdy punkt jest użyty dokładnie raz. Tasowanie zmienia grupowanie przykładów i zmniejsza zależność od ich pierwotnej kolejności.

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 obserwuj stratę po prawej. Niebieska krzywa pełnego batcha przy domyślnym współczynniku uczenia opada płynnie. Pomarańczowa krzywa minibatcha 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.

Przyjrzyj się pierwszym krokom: między 1 a 2 strata maleje, między 2 a 3 rośnie, bo aktualizacja pomaga punktom tego batcha, ale szkodzi innym. Między 3 a 4 znów maleje. Każdy batch widzi tylko część danych, więc poszczególne kroki mogą zwiększać pełną stratę. Przy domyślnych ustawieniach ogólny trend prowadzi do jej zmniejszenia.

Domyślny trening pełnym batchem zbliża się do rozwiązania w mniejszej liczbie aktualizacji, lecz każda przetwarza więcej przykładów. Porównajmy koszt 3 aktualizacji dla 5 punktów:

  • 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 wykonują 3 aktualizacje, ale minibatche przetwarzają trzy razy mniej przykładów. To porównanie pracy, nie postępu do ustalonej straty: po tych aktualizacjach metody mogą mieć różne wyniki. Dla większych zbiorów warto porównywać czas do osiągnięcia docelowego wyniku walidacji; zależy on również od sprzętu i rozmiaru batcha.

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

  • batch size = 1 — jeden punkt na aktualizację, więc aktualizacje mogą mocno różnić się między przykładami. 5 kroków to 1 epoka. To pierwotna postać stochastycznego spadku gradientu.
  • batch size = 2 — mniejszy szum, 3 kroki na epokę (2 + 2 + 1 pozostały punkt).
  • batch size = 5 — wszystkie dane naraz, czyli pełny batch. Obie linie pokrywają się. 1 krok to 1 epoka.

Rozmiar batcha wpływa na szum gradientu, zużycie pamięci i wykorzystanie sprzętu. Mniejsze batche pozwalają wykonywać więcej aktualizacji na przejście przez dane; większe uśredniają więcej przykładów w aktualizacji. Szum czasem pomaga optymalizacji, ale nie gwarantuje wyjścia z minimum lokalnego ani zbieżności do globalnego.

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 (f1′⋅f2′⋅f3′)(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.

Następny diagram ma dwie warstwy ukryte po 3 neurony i jedno wyjście skalarne. Przełączaj przyciski, aby porównać wagę w drugiej warstwie ukrytej z wagą w pierwszej:

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

W obu warstwach ukrytych użyjemy ReLU, a na wyjściu aktywacji tożsamościowej. Dla jednego przykładu treningowego przejście w przód wygląda tak:

z=W1x+b1,h=ReLU⁡(z),q=W2h+b2,g=ReLU⁡(q),y^=u⋅g+c,L=(y^−y)2.\begin{aligned} \mathbf{z} &= W_1\mathbf{x}+\mathbf{b}_1, & \mathbf{h} &= \operatorname{ReLU}(\mathbf{z}),\\ \mathbf{q} &= W_2\mathbf{h}+\mathbf{b}_2, & \mathbf{g} &= \operatorname{ReLU}(\mathbf{q}),\\ \hat y &= \mathbf{u}\cdot\mathbf{g}+c, & L &= (\hat y-y)^2. \end{aligned}

W1W_1 ma wymiar 3×23\times2, W2W_2 wymiar 3×33\times3, a wektor wag wyjścia u\mathbf{u} ma 3 elementy. z\mathbf{z} i q\mathbf{q} zawierają sumy ważone przed aktywacją; h\mathbf{h} i g\mathbf{g} wyjścia po aktywacji. Waga w1w_1 na diagramie łączy x1x_1 z h1h_1, a v1v_1 łączy h1h_1 z g1g_1. Wagi od h1h_1 do g2g_2 i g3g_3 oznaczymy v2v_2 i v3v_3. Te trzy wagi tworzą pierwszą kolumnę W2W_2.

Domyślnie aktywny jest Gradient for v₁ (layer 2). Ta waga wpływa na stratę przez q1q_1, g1g_1 i y^\hat y. Przy error=y^−yerror=\hat y-y jej pochodna wynosi:

∂L∂v1=h1⏟∂q1/∂v1⋅ReLU⁡′(q1)⏟∂g1/∂q1⋅u1⏟∂y^/∂g1⋅2 error⏟∂L/∂y^.\frac{\partial L}{\partial v_1} = \underbrace{h_1}_{\partial q_1/\partial v_1} \cdot \underbrace{\operatorname{ReLU}'(q_1)}_{\partial g_1/\partial q_1} \cdot \underbrace{u_1}_{\partial\hat y/\partial g_1} \cdot \underbrace{2\,error}_{\partial L/\partial\hat y}.

Pozostałe wejścia g1g_1 są mnożone przez inne wagi, więc nie występują w ∂q1/∂v1\partial q_1/\partial v_1. Nadal wpływają na q1q_1 i predykcję, dla których obliczamy pochodną. Zauważ, że ∂L/∂g1=u1 2 error\partial L/\partial g_1 = u_1\,2\,error: połączenie wyjściowe wnosi swoją wagę, choć różniczkujemy parametr wcześniejszej warstwy.

Wybierz Gradient for w₁ (layer 1). Zmiana w1w_1 zmienia h1h_1, które zasila wszystkie trzy neurony drugiej warstwy ukrytej. Musimy dodać wkłady wszystkich trzech ścieżek:

∂L∂w1=x1 ReLU⁡′(z1)∑j=13vj ReLU⁡′(qj) uj 2 error.\frac{\partial L}{\partial w_1} = x_1\,\operatorname{ReLU}'(z_1) \sum_{j=1}^{3} v_j\,\operatorname{ReLU}'(q_j)\,u_j\,2\,error.

Każda ścieżka wnosi iloczyn lokalnych pochodnych. W miejscach rozgałęzienia propagacja wsteczna sumuje te wkłady. Dla dwóch wyróżnionych wag otrzymujemy:

# All values come from the same forward pass; indices start at 0.
# ReLU's derivative is 1 for positive inputs, 0 for negative inputs.
# At exactly zero, we use 0 as the implementation convention.
d_output = 2 * error

# Gradients with respect to the second layer's pre-activations q.
d_q = (q > 0) * u * d_output            # shape: (3,)

# v₁ is W2[0, 0]: h₁ -> g₁.
dv1 = h[0] * d_q[0]

# Sum the three paths from h₁ through the second layer.
d_h1 = np.dot(W2[:, 0], d_q)

# w₁ is W1[0, 0]: x₁ -> h₁.
dw1 = x[0] * (z[0] > 0) * d_h1

Tymi samymi operacjami obliczymy gradienty wszystkich wag i biasów. Ten kod kończy propagację wsteczną dla jednego przykładu, a następnie aktualizuje parametry:

# Output layer: y_pred = u @ g + c
du = g * d_output                      # 3 weights
dc = d_output                         # scalar bias

# Second hidden layer: g = ReLU(W2 @ h + b2)
dW2 = np.outer(d_q, h)                 # shape: (3, 3)
db2 = d_q

# First hidden layer: h = ReLU(W1 @ x + b1)
d_z = (z > 0) * (W2.T @ d_q)
dW1 = np.outer(d_z, x)                 # shape: (3, 2)
db1 = d_z

# Apply updates only after every gradient has been computed.
u = u - lr * du
c = c - lr * dc
W2 = W2 - lr * dW2
b2 = b2 - lr * db2
W1 = W1 - lr * dW1
b1 = b1 - lr * db1

Dla minibatcha z MSE uśrednij gradienty poszczególnych przykładów przed aktualizacją. Propagacja wsteczna wykorzystuje ponownie pośrednie gradienty, takie jak d_q i d_z, więc nie musi osobno śledzić każdej ścieżki dla każdego parametru.

Dodawanie warstw wprowadza więcej wag i pochodnych aktywacji wzdłuż każdej ścieżki. Powtarzane mnożenie może znacznie zmniejszyć lub zwiększyć gradient docierający do wcześniejszej warstwy.

Problem zanikającego gradientu

W łańcuchu skalarnym, jeśli każda warstwa wnosi pochodną 0.5, przejście przez 100 warstw mnoży początkowy gradient przez 0.5100≈7.9×10−310.5^{100} \approx 7.9\times10^{-31}. Wcześniejsza warstwa otrzymuje wtedy bardzo słaby sygnał do aktualizacji. To problem zanikającego gradientu. Szersze sieci używają iloczynów macierzy i sum po ścieżkach, lecz powtarzane zmniejszanie może dać podobny efekt.

Powtarzane wzmacnianie może zamiast tego powodować eksplozję gradientów, prowadząc do niestabilnych aktualizacji.

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

Pochodna sigmoidy wynosi najwyżej 0.25, więc mnożenie wielu takich pochodnych może znacznie zmniejszyć gradient. Na jego wielkość wpływają też macierze wag; same pochodne aktywacji nie rozstrzygają, czy pełny gradient zanika, czy eksploduje.

Kilka rozwiązań architektonicznych pomaga trenować głębsze sieci:

  • ReLU ma pochodną 1 dla dodatnich wejść, więc na aktywnej gałęzi nie zmniejsza gradientu. Dla ujemnych wejść pochodna wynosi 0 i blokuje gradient przez ten neuron. Macierze wag nadal mogą go wzmacniać lub zmniejszać.
  • Połączenia rezydualne dodają ścieżkę tożsamościową omijającą blok warstw. Dla bloku h+F(h)h + F(h) pochodna zawiera składnik tożsamościowy oraz pochodną FF, dając gradientom dodatkową drogę. Zobacz publikację o ResNet.
  • Normalizacja batchowa normalizuje wartości pośrednie za pomocą statystyk minibatcha i uczy się skali oraz przesunięcia. Może ułatwić optymalizację, ale nie wymusza pochodnych równych 1 ani nie gwarantuje stabilnych gradientów. Zobacz publikację o batch normalization.

Inicjalizacja, architektura i ustawienia optymalizatora wpływają na propagację gradientów. Te techniki rozwiązują różne części problemu; żadna nie gwarantuje użytecznej skali każdego gradientu.

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.