Backpropagation od podstaw: najpierw silnik, potem sieć
Napisz 120-liniowy silnik autodiff w czystym Pythonie, sprawdź go z PyTorch do 16 miejsc i zobacz, co robi zero_grad.
Na tej stronie
Cztery rozdziały za nami, a w środku kursu wciąż jest luka.
Rozdział 3 dał nam gradient descent: aby poprawić parametr, znajdź nachylenie straty względem niego i zrób krok w dół. Rozdział 4 dał nam stratę, po której warto schodzić. Ale w obu przypadkach pochodna była liczona ręcznie — jeden model, jeden parametr, jedna linijka rachunku różniczkowego, wszystko mieściło się na stronie.
Teraz ułóż dwie warstwy. Wyjście pierwszej trafia do drugiej, więc każda waga w pierwszej wpływa na stratę przez każdy neuron w drugiej. Sieć z dwiema warstwami ukrytymi po sto jednostek każda ma około dwudziestu tysięcy parametrów, a każdy z nich potrzebuje własnej pochodnej cząstkowej tej samej straty. Robienie tego ręcznie nie jest żmudne; jest niemożliwe, i pozostaje niemożliwe dla każdej architektury w dalszej części tego kursu.
Wyjściem nie jest lepsza notacja. Jest nim zrozumienie, że pochodną złożenia można obliczyć mechanicznie, programem, na podstawie samej struktury obliczenia — i że jeśli zrobisz to we właściwym kierunku, dostaniesz wszystkie dwadzieścia tysięcy pochodnych mniej więcej kosztem jednego obliczenia straty.
Ten mechanizm to reverse-mode automatic differentiation. Zastosowany do sieci neuronowej nazywa się backpropagation, a pod koniec tego rozdziału napiszesz go w około 120 liniach Pythona, bez bibliotek, sprawdzisz go z PyTorch i użyjesz do rozwiązania problemu XOR, który pokonał perceptron w rozdziale 1.
Najpierw: dlaczego nieliniowość w ogóle musi istnieć
Link do sekcji: Najpierw: dlaczego nieliniowość w ogóle musi istniećZanim zbudujemy maszynę, trzeba rozstrzygnąć jedno pytanie, bo gdyby odpowiedź była inna, nie byłoby czego budować.
Perceptron poległ na XOR, bo jedna prosta nie potrafi rozdzielić czterech punktów. Oczywista poprawka to stos: przepuść wejście przez jedną warstwę liniową, potem przez drugą. Czy to pomaga?
Nie, a dowód ma dwie linijki. Warstwa liniowa to . Podaj ją do kolejnej, , i podstaw:
Złożenie to z i . Stos warstw liniowych jest jedną warstwą liniową. Dziesięć takich warstw, tysiąc: nadal jedna prosta, nadal niezdolna do XOR.
Warto to zobaczyć, zamiast po prostu uwierzyć:
import numpy as np
rng = np.random.default_rng(0)
W1, b1 = rng.normal(size=(3, 2)), rng.normal(size=3)
W2, b2 = rng.normal(size=(1, 3)), rng.normal(size=1)
x = rng.normal(size=2)
two_layers = W2 @ (W1 @ x + b1) + b2
one_layer = (W2 @ W1) @ x + (W2 @ b1 + b2)
print(two_layers[0], one_layer[0], abs(two_layers[0] - one_layer[0]))-4.612963371048 -4.612963371048 0.00e+00Nie w przybliżeniu równe. Identyczne bit w bit, bo to ta sama arytmetyka, tylko przestawiona.
Sama głębokość nic więc nie kupuje. Coś kupuje dopiero wstawienie funkcji nieliniowej między warstwy — i to jest cały powód istnienia funkcji aktywacji. Nie są biologicznym ozdobnikiem ani sztuczką normalizacyjną. Bez nich druga warstwa jest dekoracją.
Reguła łańcuchowa, na papierze, ze współdzielonym węzłem
Link do sekcji: Reguła łańcuchowa, na papierze, ze współdzielonym węzłemTeraz matematyka, i jest to jedna reguła, którą już znasz, zastosowana w nieco mniej znajomym miejscu.
Jednowymiarowa reguła łańcuchowa mówi, że jeśli zależy od , a zależy od , to . Pochodne mnożą się wzdłuż łańcucha.
Część, która ma tutaj znaczenie, to to, co dzieje się, gdy zmienna zasila więcej niż jedną ścieżkę w dół grafu. Jeśli wpływa na przez , a także przez , wkłady się dodają:
Mnożenie wzdłuż ścieżki, suma między ścieżkami. To całe backpropagation, a każdy szczegół implementacyjny w dalszej części rozdziału — włącznie z += w kodzie i wywołaniem zero_grad(), o które potyka się każdy piszący swoją pierwszą pętlę treningową — jest bezpośrednią konsekwencją tego drugiego słowa.
Weźmy konkretny układ pięciu operacji, z i :
Zauważ, że pojawia się trzy razy: w , w i bezpośrednio w . Wykonaj backward pass na papierze, od prawej do lewej, zaczynając od :
Przez dodawanie
Link do sekcji: Przez dodawanie, więc , a ścieżka bezpośrednia wnosi . Dodawanie rozdziela przychodzący gradient bez zmian na oba wejścia.
Przez tanh
Link do sekcji: Przez tanhz , więc .
Przez mnożenie
Link do sekcji: Przez mnożenie, więc i . Mnożenie zamienia: gradient każdego wejścia jest skalowany przez wartość drugiego wejścia.
Zbierz trzy ścieżki do x
Link do sekcji: Zbierz trzy ścieżki do xPrzez : . Przez : . Bezpośrednio: .
Zapamiętaj tę liczbę. Za kilka stron program wygeneruje ją bez żadnej wiedzy o tym wszystkim.
Budowanie silnika
Link do sekcji: Budowanie silnikaSpostrzeżenie, które czyni to programowalnym: każdy z tych kroków był lokalny. Aby przepchnąć gradient przez węzeł mnożenia, potrzebujesz przychodzącego gradientu i dwóch zapisanych wartości wejściowych — niczego o reszcie układu. Każda operacja wie, jak zróżniczkować samą siebie.
Zrób więc liczbę, która pamięta, co ją wytworzyło.
class Value:
"""A number that remembers where it came from."""
def __init__(self, data, _children=(), _op=""):
self.data = data
self.grad = 0.0
self._backward = lambda: None
self._prev = set(_children)
self._op = _opCztery pola. data to wartość. grad akumuluje . _prev to zbiór Value, z których ta wartość została obliczona — krawędzie grafu. A _backward to domknięcie instalowane przez każdą operację: wie, jak przepchnąć gradient tego węzła o jeden krok wstecz do jego wejść.
Każdy operator ma ten sam kształt: oblicz wyjście, zapisz rodziców, zainstaluj lokalną regułę.
def __add__(self, other):
other = other if isinstance(other, Value) else Value(other)
out = Value(self.data + other.data, (self, other), "+")
def _backward():
self.grad += out.grad
other.grad += out.grad
out._backward = _backward
return out
def __mul__(self, other):
other = other if isinstance(other, Value) else Value(other)
out = Value(self.data * other.data, (self, other), "*")
def _backward():
self.grad += other.data * out.grad
other.grad += self.data * out.grad
out._backward = _backward
return out
def tanh(self):
t = math.tanh(self.data)
out = Value(t, (self,), "tanh")
def _backward():
self.grad += (1 - t * t) * out.grad
out._backward = _backward
return out
def relu(self):
out = Value(self.data if self.data > 0 else 0.0, (self,), "relu")
def _backward():
self.grad += (1.0 if out.data > 0 else 0.0) * out.grad
out._backward = _backward
return outCzytaj cztery ciała _backward jak tabelę, a wzorce przepływu z papierowego wyprowadzenia są tuż przed tobą:
| operacja | co robi z gradientem |
|---|---|
+ | rozdziela — ten sam gradient do każdego wejścia |
* | zamienia — każde wejście skalowane wartością drugiego |
relu | przepuszcza — przekazuje dalej albo całkowicie blokuje |
tanh | tłumi — skaluje przez , które wynosi najwyżej 1 i zwykle mniej |
Każda z nich używa +=, a nigdy =. To zakodowana reguła „suma między ścieżkami”. Węzeł zasilający dwóch konsumentów zostaje wywołany dwa razy, a dwa wkłady same się dodają.
Potem sterownik, jedyna część z jakąkolwiek globalną wiedzą:
def backward(self):
order, seen = [], set()
def build(v):
if v in seen:
return
seen.add(v)
for child in v._prev:
build(child)
order.append(v)
build(self)
self.grad = 1.0
for v in reversed(order):
v._backward() build tworzy topologiczne uporządkowanie grafu: każdy węzeł pojawia się po wszystkich swoich wejściach. Przejście po tej liście w odwrotnej kolejności gwarantuje, że gdy wywołujesz _backward węzła, jego własny gradient jest już kompletny — każdy konsument poniżej już dołożył swój wkład. Pomyl kolejność, a przepchniesz wstecz niedokończony gradient, co da złą odpowiedź bez żadnego komunikatu o błędzie.
Czy zgadza się z papierem?
Link do sekcji: Czy zgadza się z papierem?x = Value(0.5)
y = Value(1.4)
a = x * y
b = x + y
c = a * b
d = c.tanh()
L = d + x
L.backward()
print(x.grad, y.grad)forward: a=0.7000 b=1.9000 c=1.3300 d=0.8692 L=1.3692
backward: dL/dd=1.0000 dL/dc=0.2444 dL/da=0.4644 dL/db=0.1711
dL/dx=1.8212 dL/dy=0.40331.8212. Ta sama liczba, z programu, któremu podano regułę dla +, regułę dla *, regułę dla tanh i nic o tym konkretnym układzie.
Dwa niezależne sprawdzenia, bo „zgadza się z tym, co sam wyprowadziłem” to słaby test, gdy ta sama osoba zrobiła jedno i drugie.
Różniczkowanie numeryczne. Przesuń wejście i zmierz. Różnica centralna estymuje pochodną bez żadnego rachunku różniczkowego:
dL/dx: analytic=1.821202805 numeric=1.821202805 |diff|=1.80e-10
dL/dy: analytic=0.403269235 numeric=0.403269235 |diff|=7.64e-12Z PyTorch, który ma przemysłowy silnik autodiff napisany przez ludzi robiących to zawodowo:
torch dL/dx=1.821202805316 ours=1.821202805316 |diff|=2.22e-16
torch dL/dy=0.403269234753 ours=0.403269234753 |diff|=1.11e-16Zgodność na poziomie , czyli machine epsilon dla 64-bitowej liczby zmiennoprzecinkowej: oba silniki wykonują identyczną arytmetykę. Zachowaj test numeryczny pod ręką — to narzędzie do debugowania backward pass nowej warstwy i powód, dla którego błędny gradient da się w ogóle znaleźć.
Nasycenie, zmierzone
Link do sekcji: Nasycenie, zmierzoneTen sam układ, inne wejścia. Ustaw i , co daje :
x=0.5, y=1.4: dL/dc = 0.244400 three paths into x: 0.6501 + 0.1711 + 1.0000 = 1.8212
x=2.0, y=-3.0: dL/dc = 0.000025 three paths into x: 0.0001 + -0.0001 + 1.0000 = 0.9999Gradient przechodzący przez węzeł spadł 9 945 razy. Wszystko przed nim — w prawdziwej sieci każda wcześniejsza warstwa — dostaje w praktyce nic. Dwie ścieżki przez układ zamilkły; tylko bezpośrednie połączenie omijające nadal niesie sygnał.
To problem zanikającego gradientu w jednym węźle. Ułóż czterdzieści warstw i pomnóż czterdzieści takich czynników, a wczesne warstwy przestaną się uczyć całkowicie. Przy okazji jest to też argument za połączeniami skip, widoczny tu w miniaturze: ścieżka omijająca nieliniowość była jedyną, która przetrwała.
Co naprawdę robi zero_grad i dlaczego błąd się ukrywa
Link do sekcji: Co naprawdę robi zero_grad i dlaczego błąd się ukrywaKażde _backward używa +=. To poprawne — tak sumują się ścieżki. Ale ma to konsekwencję, na której potyka się każdy: gradienty akumulują się także między wywołaniami backward(). Silnik nie ma pojęcia, że twoje drugie wywołanie to nowy krok treningowy, a nie kolejna ścieżka w tym samym grafie.
Dlatego pętla treningowa musi je czyścić:
for step in range(steps):
ys = [model(x) for x, _ in DATA]
loss = sum((yp - yt) ** 2 for yp, (_, yt) in zip(ys, DATA))
for p in model.parameters():
p.grad = 0.0
loss.backward()
for p in model.parameters():
p.data -= lr * p.gradW PyTorch jest to optimizer.zero_grad(), a zwykła rada brzmi: jeśli o tym zapomnisz, trening się zepsuje. Usuńmy więc te dwie linie i zobaczmy, jak bardzo jest zepsuty. Te same ziarna, to samo wszystko, 200 kroków XOR:
| learning rate | seed | z resetem | bez resetu |
|---|---|---|---|
| 0.05 | 1337 | loss 3.255088, 3/4 | loss 0.000000, 4/4 |
| 0.05 | 7 | loss 2.144820, 2/4 | loss 0.000000, 4/4 |
| 0.05 | 42 | loss 2.126074, 2/4 | loss 0.000000, 4/4 |
| 0.1 | 1337 | loss 0.038597, 4/4 | loss 0.000000, 4/4 |
| 0.1 | 7 | loss 2.055048, 2/4 | loss 0.000000, 4/4 |
| 0.1 | 42 | loss 2.049876, 2/4 | loss 0.000073, 4/4 |
| 0.3 | 1337 | loss 4.512310, 2/4 | loss 8.000000, 2/4 |
| 0.3 | 7 | loss 0.015247, 4/4 | loss 4.000000, 3/4 |
| 0.3 | 42 | loss 0.005478, 4/4 | loss 4.000000, 3/4 |
Przy małych learning rate błędna wersja wygrywa w każdym wierszu. Zbiega, gdy poprawna wersja utyka.
To nie przypadek i warto to zrozumieć, bo wyjaśnia, dlaczego ten błąd tak trudno złapać. Jeśli nigdy nie czyścisz gradientu, to w kroku parametr jest aktualizowany przez sumę wszystkich dotychczas obliczonych gradientów. Przy stracie, która nadal wskazuje mniej więcej ten sam kierunek, ta suma stale rośnie, a efekt wygląda jak learning rate, który sam się zwiększa. Przy , gdy poprawny algorytm pełznie, uciekający rozmiar kroku wygląda dokładnie jak naprawa.
Potem spójrz na trzy dolne wiersze. Przy ten sam mechanizm rozrywa model — loss 8.0 to wynik modelu zapadniętego do stałej — połowa z 16, które kosztowałyby cztery maksymalnie błędne odpowiedzi — — podczas gdy poprawna wersja zbiega już czysto.
Uczciwe stwierdzenie nie brzmi więc „zawsze wywołuj zero_grad, inaczej model się nie wytrenuje”. Brzmi: bez tego nie uruchamiasz już gradient descent. Uruchamiasz coś, czego rozmiar kroku dryfuje w górę w tempie, którego nikt nie wybrał, i będzie wyglądać, jakby działało — czasem lepiej niż prawdziwa rzecz — aż przestanie. Wtedy obwinisz learning rate, inicjalizację albo dane. Tak wyglądają najgorsze błędy w machine learning: nie crashują, tylko zmieniają algorytm w inny algorytm, który od czasu do czasu osiąga lepszy wynik.
Sieć i wreszcie XOR
Link do sekcji: Sieć i wreszcie XORGdy silnik jest gotowy, sieć neuronowa to już ledwie trochę kodu. Neuron to iloczyn skalarny, bias i aktywacja; warstwa to lista neuronów; sieć to lista warstw.
class Neuron:
def __init__(self, nin):
self.w = [Value(random.uniform(-1, 1)) for _ in range(nin)]
self.b = Value(0.0)
def __call__(self, x):
act = sum((wi * xi for wi, xi in zip(self.w, x)), self.b)
return act.tanh()
def parameters(self):
return self.w + [self.b]
class Layer:
def __init__(self, nin, nout):
self.neurons = [Neuron(nin) for _ in range(nout)]
def __call__(self, x):
out = [n(x) for n in self.neurons]
return out[0] if len(out) == 1 else out
def parameters(self):
return [p for n in self.neurons for p in n.parameters()]
class MLP:
def __init__(self, nin, nouts):
sizes = [nin] + nouts
self.layers = [Layer(sizes[i], sizes[i + 1]) for i in range(len(nouts))]
def __call__(self, x):
for layer in self.layers:
x = layer(x)
return x
def parameters(self):
return [p for layer in self.layers for p in layer.parameters()]W tym wszystkim nie ma backward pass. Ani jednej linijki. Klasa Value już wie, jak różniczkować wszystko, co te klasy akurat zbudują, i o to chodziło w napisaniu jej najpierw: silnik autodiff nie wie, że jest używany do sieci neuronowej.
Teraz problem z rozdziału 1. Dwa wejścia, dwie jednostki ukryte, jedno wyjście, dziewięć parametrów:
step 1: loss 4.156690
step 10: loss 4.005572
step 50: loss 3.996708
step 100: loss 3.510700
step 200: loss 0.038597
[0, 0] -> -0.9081 (target -1) ok
[0, 1] -> +0.8934 (target +1) ok
[1, 0] -> +0.8906 (target +1) ok
[1, 1] -> -0.9207 (target -1) okCztery na cztery. Funkcja, której nie potrafi obliczyć żaden perceptron — co w rozdziale 1 udowodniliśmy czterema nierównościami wymagającymi, by było jednocześnie dodatnie i ujemne — jest obliczana przez dziewięć liczb znalezionych automatycznie.
Co zrobiła warstwa ukryta
Link do sekcji: Co zrobiła warstwa ukrytaSatysfakcjonujące nie jest to, że działa. Satysfakcjonujące jest to, że można zobaczyć jak, bo przy dwóch jednostkach ukrytych reprezentacja pośrednia jest punktem na płaszczyźnie i można ją po prostu wypisać.
Po treningu do loss 0.001241, oto gdzie każde wejście ląduje po warstwie ukrytej i co robi z nim neuron wyjściowy:
| wejście | wyjście warstwy ukrytej | wynik wyjścia | etykieta |
|---|---|---|---|
Spójrz na pierwszy i czwarty wiersz. Wejścia i to przeciwległe po przekątnej rogi kwadratu — tak daleko od siebie, jak tylko mogą być dwa punkty w tym problemie — a warstwa ukryta mapuje je na i . Prawie ten sam punkt. Warstwa złożyła płaszczyznę tak, że dwa odrzucone rogi wylądowały na sobie, a gdy są już w tym samym miejscu, jedna prosta oddziela je od pozostałych dwóch.
Neuron wyjściowy jest dokładnie tą prostą. Jego nauczone parametry to , , więc jego granica decyzyjna to
czyli prosta — perceptron, ten sam obiekt z rozdziału 1, bez zmian. Wtedy nie potrafił rozwiązać XOR i teraz też nie potrafi. Zmieniło się to, że nie patrzy już na wejście; patrzy na przestrzeń, którą pierwsza warstwa dla niego zbudowała i w której problem jest liniowo separowalny.
To właśnie jest nauczona reprezentacja, i warto być precyzyjnym, bo to wyrażenie będzie używane luźno przez resztę tego kursu i przez resztę dziedziny. Nie jest kompresją, streszczeniem ani embedding w żadnym mistycznym sensie. Jest zmianą współrzędnych, nauczoną zamiast zaprojektowanej, której jedynym zadaniem jest ułatwić pracę następnej warstwie.
Twierdzenie o uniwersalnej aproksymacji i czego ono nie mówi
Link do sekcji: Twierdzenie o uniwersalnej aproksymacji i czego ono nie mówiJest tu twierdzenie, które zwykle cytuje się źle.
Cybenko w 1989 i Hornik w 1991 udowodnili, że sieć feedforward z pojedynczą warstwą ukrytą i odpowiednią funkcją aktywacji może aproksymować dowolną funkcję ciągłą na zbiorze zwartym z dowolną dokładnością, jeśli ma wystarczająco dużo jednostek ukrytych.34 To prawdziwy i ważny wynik: mówi, że architektura nie jest ograniczeniem.
Teraz przeczytaj, co pomija. Nie mówi, ile jednostek — granica może być astronomicznie duża. Nie mówi, że wagi można znaleźć; stwierdza istnienie, a gradient descent z losowego startu nie jest wyrocznią. I nie mówi nic o zachowaniu na danych, których nie widziałeś, czyli drugiej połowie rozdziału 6.
Przepaść między „istnieje” a „da się znaleźć” nie jest akademicka. Oto ten sam problem XOR, po 50 losowych inicjalizacji, 1000 kroków, zmieniono tylko rozmiar warstwy ukrytej:
| jednostki ukryte | inicjalizacje osiągające 4/4 |
|---|---|
| 2 | 38 / 50 (76 %) |
| 3 | 49 / 50 (98 %) |
| 4 | 50 / 50 (100 %) |
| 8 | 47 / 50 (94 %) |
Przy minimalnej działającej architekturze jedno uruchomienie na cztery nigdy tam nie dociera — osiada w konfiguracji, z której nie potrafi zejść, dokładnie w minimum lokalnym, które rozdział 3 pokazał na jednowymiarowej powierzchni. Dodaj jedną jednostkę, a porażki prawie znikają — nie dlatego, że sieć stała się bardziej ekspresyjna (dwie jednostki już wystarczają, 38 uruchomień to dowodzi), lecz dlatego, że dodatkowe wymiary dają zejściu więcej kierunków ucieczki.
A potem osiem jednostek wypada odrobinę gorzej niż cztery. Przy stałym learning rate i budżecie kroków większa pojemność nie jest monotonicznie lepsza. Każdy, kto mówi ci, że naprawą utkniętej sieci zawsze jest większa sieć, ekstrapoluje ze środka tej tabeli.
To ta sama lekcja co twierdzenie o zbieżności z rozdziału 1, i będzie to ta sama lekcja w rozdziale 10 o scaling laws, w formie nadanej jej przez tamten rozdział: predykcja straty nie jest predykcją capability, za którą płacisz, a odległość między nimi to miejsce, w którym żyje inżynieria.
Pokaż szczegóły
Opcjonalnie: postać macierzowa i dlaczego powyższy kod jej nie używa.
Wszystko tutaj zostało zapisane po jednym skalarze naraz, co jest najjaśniejszym sposobem zobaczenia mechanizmu i najwolniejszym sposobem jego wykonania. W praktyce warstwa to mnożenie macierzy, a backward pass dla to
Transpozycje nie są sztuczką do zapamiętania; są tym, jak wygląda reguła sumy między ścieżkami, gdy ścieżki indeksują wpisy macierzy. Obiektem ogólnym jest Jakobian, macierz wszystkich pochodnych cząstkowych wszystkich wyjść względem wszystkich wejść, a reverse mode jest dokładnie obliczeniem iloczynu wektor-Jakobian bez materializowania Jakobianu — co ma znaczenie, bo dla warstwy z 4096 wejściami i 4096 wyjściami taka macierz ma szesnaście milionów wpisów i nigdy nie warto jej budować.
Nie potrzebujesz niczego z tego, aby śledzić następne rozdziały; wersja skalarna robi wszystko to, co wersja macierzowa, tylko wolniej. Staje się to konieczne w rozdziale 9, gdzie kształty przestają być oczywiste.
Dokąd to prowadzi dalej
Link do sekcji: Dokąd to prowadzi dalejMasz teraz sieć, która się trenuje. To mniejsze osiągnięcie, niż się wydaje, bo twoja sieć trenuje na czterech przykładach i jest mierzona na tych samych czterech.
Uruchom ten sam kod na prawdziwym zbiorze danych, a pojawi się nowy zestaw problemów, z których żaden nie dotyczy gradientów. Strata przez chwilę spada, a potem się zatrzymuje. Albo spada na danych treningowych i rośnie na wszystkim innym. Albo nie rusza się wcale od pierwszego kroku, a przyczyną okazuje się zakres początkowych losowych wag. Albo wejście jednej jednostki przesunęło się na ujemne dla każdego przykładu w trzeciej epoce i od tamtej pory jest martwa, po cichu zabierając kawałek pojemności modelu.
To nie są egzotyczne awarie; to normalny stan sieci, która dopiero została napisana, i żadna z nich sama się nie ogłasza. Gradient jest poprawny — sprawdziłeś go z PyTorch do szesnastu miejsc po przecinku — a model nadal się nie uczy.
Rozdział 6 jest o tym: inicjalizacja, normalizacja, overfitting i regularyzacja oraz diagnostyczny nawyk pytania, który z tych problemów występuje, zanim cokolwiek zmienisz. To różnica między siecią, która działa, a siecią, która spełnia swoje zadanie.
Źródła i metoda
Link do sekcji: Źródła i metodaKlasa Value w tym rozdziale wywodzi się bezpośrednio z micrograd Andreja Karpathy’ego, a jego wideo The spelled-out intro to neural networks and backpropagation: building micrograd to najlepsze trzy godziny, jakie możesz spędzić nad tym materiałem, jeśli chcesz usłyszeć drugie wyjaśnienie od kogoś innego. Jego wpis z 2016 roku Yes you should understand backprop wyjaśnia, dlaczego warto napisać to samodzielnie, i jest lekturą obowiązkową w Stanford CS224n. Notatki CS231n o backpropagation (cs231n.github.io/optimization-2) to kanoniczne omówienie wzorców przepływu zestawionych powyżej. Jeśli chodzi o matematykę jako rachunek różniczkowy na grafie, a nie folklor sieci neuronowych, rozdział 5.6 książki Mathematics for Machine Learning autorstwa Deisenrotha, Faisala i Onga jest wyjątkowo klarowny; a przegląd Baydina, Pearlmuttera, Radula i Siskinda Automatic Differentiation in Machine Learning: a Survey (arXiv:1502.05767) jest punktem odniesienia dla całej dziedziny, włącznie z omówionym wyżej kompromisem forward/reverse.
Przypisy
Link do sekcji: Przypisy-
Linnainmaa, S. The representation of the cumulative rounding error of an algorithm as a Taylor expansion of the local rounding errors. Praca magisterska, University of Helsinki (1970). Akumulacja w reverse-mode, szesnaście lat przed tym, jak dotarła do tej dziedziny, i z zupełnie innej motywacji. ↩
-
Rumelhart, D. E., Hinton, G. E. and Williams, R. J. Learning representations by back-propagating errors. Nature 323, s. 533–536 (1986). Artykuł, który uczynił metodę znaną, oraz źródło odczytywania jednostek ukrytych jako nauczonych reprezentacji, na którym sekcja Co zrobiła warstwa ukryta w tym rozdziale opiera swoje pomiary. ↩
-
Cybenko, G. Approximation by superpositions of a sigmoidal function. Mathematics of Control, Signals and Systems 2, s. 303–314 (1989). ↩
-
Hornik, K. Approximation capabilities of multilayer feedforward networks. Neural Networks 4(2), s. 251–257 (1991). Uogólnia Cybenkę: wynik zależy od tego, czy aktywacja jest niewielomianowa, a nie od tego, czy jest sigmoidalna. ↩