Przejdź do treści
5/30Rozdział 5 z 30

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 h=W1x+b1\mathbf{h} = W_1\mathbf{x} + \mathbf{b}_1. Podaj ją do kolejnej, y=W2h+b2\mathbf{y} = W_2\mathbf{h} + \mathbf{b}_2, i podstaw:

y=W2(W1x+b1)+b2=(W2W1)x+(W2b1+b2)\mathbf{y} = W_2(W_1\mathbf{x} + \mathbf{b}_1) + \mathbf{b}_2 = (W_2W_1)\mathbf{x} + (W_2\mathbf{b}_1 + \mathbf{b}_2)

Złożenie to Wx+bW\mathbf{x} + \mathbf{b} z W=W2W1W = W_2W_1 i b=W2b1+b2\mathbf{b} = W_2\mathbf{b}_1 + \mathbf{b}_2. 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ć:

linear_is_linear.pyPYTHON
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]))
TEXT
-4.612963371048  -4.612963371048  0.00e+00

Nie 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łem

Teraz 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 LL zależy od cc, a cc zależy od xx, to dLdx=dLdcdcdx\frac{dL}{dx} = \frac{dL}{dc} \cdot \frac{dc}{dx}. 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 xx wpływa na LL przez aa, a także przez bb, wkłady się dodają:

dLdx=Laax+Lbbx\frac{dL}{dx} = \frac{\partial L}{\partial a}\frac{\partial a}{\partial x} + \frac{\partial L}{\partial b}\frac{\partial b}{\partial x}

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 x=0.5x = 0.5 i y=1.4y = 1.4:

a=xy,b=x+y,c=ab,d=tanh(c),L=d+xa = xy, \quad b = x + y, \quad c = ab, \quad d = \tanh(c), \quad L = d + x

Zauważ, że xx pojawia się trzy razy: w aa, w bb i bezpośrednio w LL. Wykonaj backward pass na papierze, od prawej do lewej, zaczynając od dLdL=1\frac{dL}{dL} = 1:

L=d+xL = d + x, więc Ld=1\frac{\partial L}{\partial d} = 1, a ścieżka bezpośrednia wnosi Lx=1\frac{\partial L}{\partial x} = 1. Dodawanie rozdziela przychodzący gradient bez zmian na oba wejścia.

d=tanh(c)d = \tanh(c) z c=ab=0.7×1.9=1.33c = ab = 0.7 \times 1.9 = 1.33, więc dLdc=1tanh2(1.33)=0.2444\frac{dL}{dc} = 1 - \tanh^2(1.33) = 0.2444.

c=abc = ab, więc dLda=dLdcb=0.2444×1.9=0.4644\frac{dL}{da} = \frac{dL}{dc} \cdot b = 0.2444 \times 1.9 = 0.4644 i dLdb=dLdca=0.2444×0.7=0.1711\frac{dL}{db} = \frac{dL}{dc} \cdot a = 0.2444 \times 0.7 = 0.1711. Mnożenie zamienia: gradient każdego wejścia jest skalowany przez wartość drugiego wejścia.

Przez aa: dLday=0.4644×1.4=0.6501\frac{dL}{da} \cdot y = 0.4644 \times 1.4 = 0.6501. Przez bb: dLdb1=0.1711\frac{dL}{db} \cdot 1 = 0.1711. Bezpośrednio: 11.

dLdx=0.6501+0.1711+1.0000=1.8212\frac{dL}{dx} = 0.6501 + 0.1711 + 1.0000 = 1.8212

Zapamiętaj tę liczbę. Za kilka stron program wygeneruje ją bez żadnej wiedzy o tym wszystkim.

Spostrzeż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.

value.pyPYTHON
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 = _op

Cztery pola. data to wartość. grad akumuluje Lself\frac{\partial L}{\partial \text{self}}. _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łę.

value.py (continued)PYTHON
    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 out

Czytaj cztery ciała _backward jak tabelę, a wzorce przepływu z papierowego wyprowadzenia są tuż przed tobą:

operacjaco robi z gradientem
+rozdziela — ten sam gradient do każdego wejścia
*zamienia — każde wejście skalowane wartością drugiego
reluprzepuszcza — przekazuje dalej albo całkowicie blokuje
tanhtłumi — skaluje przez 1t21 - t^2, 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ą:

value.py (continued)PYTHON
    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.

check.pyPYTHON
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)
TEXT
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.4033

1.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 L(x+h)L(xh)2h\frac{L(x+h) - L(x-h)}{2h} estymuje pochodną bez żadnego rachunku różniczkowego:

TEXT
dL/dx:  analytic=1.821202805  numeric=1.821202805  |diff|=1.80e-10
dL/dy:  analytic=0.403269235  numeric=0.403269235  |diff|=7.64e-12

Z PyTorch, który ma przemysłowy silnik autodiff napisany przez ludzi robiących to zawodowo:

TEXT
torch dL/dx=1.821202805316   ours=1.821202805316   |diff|=2.22e-16
torch dL/dy=0.403269234753   ours=0.403269234753   |diff|=1.11e-16

Zgodność na poziomie 2×10162 \times 10^{-16}, 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źć.

Ten sam układ, inne wejścia. Ustaw x=2x = 2 i y=3y = -3, co daje c=6c = 6:

TEXT
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.9999

Gradient przechodzący przez węzeł tanh\tanh 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 tanh\tanh nadal niesie sygnał.

To problem zanikającego gradientu w jednym węźle. Ułóż czterdzieści warstw tanh\tanh 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ę ukrywa

Każ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ć:

train.pyPYTHON
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.grad

W 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 rateseedz resetembez resetu
0.051337loss 3.255088, 3/4loss 0.000000, 4/4
0.057loss 2.144820, 2/4loss 0.000000, 4/4
0.0542loss 2.126074, 2/4loss 0.000000, 4/4
0.11337loss 0.038597, 4/4loss 0.000000, 4/4
0.17loss 2.055048, 2/4loss 0.000000, 4/4
0.142loss 2.049876, 2/4loss 0.000073, 4/4
0.31337loss 4.512310, 2/4loss 8.000000, 2/4
0.37loss 0.015247, 4/4loss 4.000000, 3/4
0.342loss 0.005478, 4/4loss 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 kk 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 η=0.05\eta = 0.05, gdy poprawny algorytm pełznie, uciekający rozmiar kroku wygląda dokładnie jak naprawa.

Potem spójrz na trzy dolne wiersze. Przy η=0.3\eta = 0.3 ten sam mechanizm rozrywa model — loss 8.0 to wynik modelu zapadniętego do stałej ±1\pm 1 — 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.

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

nn.pyPYTHON
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:

TEXT
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)  ok

Cztery na cztery. Funkcja, której nie potrafi obliczyć żaden perceptron — co w rozdziale 1 udowodniliśmy czterema nierównościami wymagającymi, by bb było jednocześnie dodatnie i ujemne — jest obliczana przez dziewięć liczb znalezionych automatycznie.

Satysfakcjonują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ściewyjście warstwy ukrytejwynik wyjściaetykieta
(0,0)(0, 0)(+0.8206, 0.8474)(+0.8206,\ -0.8474)2.4045-2.40451-1
(0,1)(0, 1)(+0.9985, +0.8564)(+0.9985,\ +0.8564)+2.3049+2.3049+1+1
(1,0)(1, 0)(0.8401, 0.9990)(-0.8401,\ -0.9990)+2.3006+2.3006+1+1
(1,1)(1, 1)(+0.8368, 0.8550)(+0.8368,\ -0.8550)2.4786-2.47861-1

Spójrz na pierwszy i czwarty wiersz. Wejścia (0,0)(0,0) i (1,1)(1,1) 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 (0.82,0.85)(0.82, -0.85) i (0.84,0.86)(0.84, -0.86). 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 w=(3.1153, +3.0893)\mathbf{w} = (-3.1153,\ +3.0893), b=+2.7697b = +2.7697, więc jego granica decyzyjna to

3.1153h1+3.0893h2+2.7697=0-3.1153\,h_1 + 3.0893\,h_2 + 2.7697 = 0

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ówi

Jest 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 ukryteinicjalizacje osiągające 4/4
238 / 50 (76 %)
349 / 50 (98 %)
450 / 50 (100 %)
847 / 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 y=Wx\mathbf{y} = W\mathbf{x} to

LW=Lyx,Lx=WLy\frac{\partial L}{\partial W} = \frac{\partial L}{\partial \mathbf{y}}\mathbf{x}^\top, \qquad \frac{\partial L}{\partial \mathbf{x}} = W^\top\frac{\partial L}{\partial \mathbf{y}}

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.

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


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

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

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

  3. Cybenko, G. Approximation by superpositions of a sigmoidal function. Mathematics of Control, Signals and Systems 2, s. 303–314 (1989).

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

Gotowy, żeby to LIA wybierała za Ciebie?

Twórz ze wszystkimi modelami AI w jednym miejscu — zacznij dziś za darmo.