Od nachylenia do propagacji wstecznej: zbuduj mały silnik autogradowy

W naszym artykule na temat uczenia się sieci neuronowych prześledziliśmy pętlę treningową modelu: dokonaliśmy prognozy, zmierzyliśmy błąd za pomocą funkcji straty, obliczyliśmy gradienty i zaktualizowaliśmy wagi. Aby dostosować wagi, użyliśmy gradientów — pochodnych, które mówią nam, jak zmienia się strata w odniesieniu do każdej wagi. Zejście gradientowe wykorzystuje te pochodne do wyboru kierunku aktualizacji.

W tym artykule chcemy się skupić na sposobie obliczania tych gradientów. Jeśli strata modelu wynosi 99, jak możemy obliczyć, że zwiększenie jednej wagi spowoduje jej zmniejszenie? Którą funkcję różniczkujemy i skąd wynikają reguły jej pochodnej?

Aby odpowiedzieć na to pytanie, musimy zastosować się do obliczeń, które spowodowały stratę. Przewidywanie i strata razem tworzą funkcję parametrów modelu. Będziemy budować na podstawie f(x)=x2f(x)=x^2, obliczać pochodne poprzez poszczególne operacje i implementować mały silnik automatycznego różnicowania w Pythonie. Następnie użyjemy go do uczenia modelu.

Nasz silnik wykorzystuje automatyczne różnicowanie w trybie odwrotnym, czyli autodiff: rejestruje operacje w ocenie w przód przy bieżących wartościach parametrów, a następnie stosuje reguły pochodne wstecz. Będziemy również używać różnic skończonych do dwóch odrębnych celów: wizualizacji, co oznacza nachylenie i niezależnego sprawdzania odpowiedzi silnika. Te szacunki dotyczące pobliskich punktów należą do wyjaśnienia i weryfikacji; pętla treningowa pobiera gradienty z autodiff.

Potrzebujesz tylko podstawowego języka Python i algebry. Wprowadzimy rachunek różniczkowy tak, jak go używamy. Implementacja jest zgodna z podejściem obliczeniowym skalarnym nauczanym w wykładzie mikrogradowym Andreja Karpathy’ego, przy użyciu jawnej tabeli lokalnych pochodnych na krawędziach wykresu. Nie wymaga bibliotek uczenia maszynowego.

Zacznij od funkcji i jej pochodnej

Rozważ:

f(x)=x2.f(x)=x^2.

W x=5x=5 wyjście to 2525. Reguła potęgi podaje jej pochodną zapisaną jako f′(x)f'(x) lub df/dxdf/dx:

f′(x)=2x,f′(5)=10.f'(x)=2x,\qquad f'(5)=10.

Pochodna jest nachyleniem stycznej w tym punkcie. Mówi nam, jak czułe jest wyjście na niewielką zmianę sygnału wejściowego:

f(5+Δx)≈25+10Δx.f(5+\Delta x)\approx25+10\Delta x.

Pochodna to pożądana ilość. Istnieje kilka metod obliczenia lub przybliżenia tej ilości.

Trzy sposoby uzyskania tej samej pochodnej

Zachowaj stałą funkcję f(x)=x2f(x)=x^2 i punkt oceny x=5x=5:

MetodaCzego używaObliczenia w x=5x=5Wynik
Symboliczne zróżnicowanieReguły stosowane do wyrażeniaWyprowadź 2x2x, następnie zamień 551010
Skończone różniceWyjścia na pobliskich wejściach(f(5.001)−f(5))/0.001(f(5.001)-f(5))/0.001Około 10.00110.001
Automatyczne różnicowanieWykonywane operacje i ich lokalne reguły dotyczące instrumentów pochodnychDla x * x dodaj wkłady 5+55+51010

Zróżnicowanie symboliczne daje wyrażenie pochodne, które możemy ocenić później. Funkcja Autodiff ocenia reguły pochodne na podstawie wartości zarejestrowanych podczas obliczeń. W przypadku tej gładkiej funkcji oba obliczają pochodną w wybranym punkcie, z zastrzeżeniem arytmetyki zmiennoprzecinkowej w kodzie. Różnice skończone szacują to poprzez porównanie wyników w różnych punktach.

Wsteczna propagacja sieci neuronowej wykorzystuje trzecie podejście. Dostarczamy reguły pochodne dla podstawowych operacji, a silnik łączy je za pomocą reguły łańcuchowej. Może obliczyć gradient przy bieżących wartościach parametrów bez generowania jednego dużego wyrażenia symbolicznego dla całej sieci.

Automatyczne różnicowanie poprzez jedno mnożenie

Napisz tę samą funkcję co program:

def f(x):
    return x * x

Znamy już regułę pochodnej mnożenia. Jeśli z=abz=ab, jego lokalnymi pochodnymi są:

∂z∂a=b,∂z∂b=a.\frac{\partial z}{\partial a}=b, \qquad \frac{\partial z}{\partial b}=a.

W przypadku x * x oba operandy odnoszą się do tego samego wejścia. W x=5x=5 każdy operand to 55, więc każda lokalna pochodna to także 55. Zmiana w xx wpływa na wynik poprzez obie ścieżki argumentów. Ich wkład dodaje:

d(x⋅x)dx=5+5=10at x=5.\frac{d(x\cdot x)}{dx}=5+5=10 \quad\text{at }x=5.

Wybierz Evaluate x × x, a następnie Seed output gradient. Postępuj zgodnie z każdym operandem po kolei, aby zobaczyć skumulowany gradient sięgający 1010.

Experiment 1 · automatic differentiationOne input, two operand paths
Differentiate x times x through its two operand pathsBoth paths connect the same input x=5 to one multiplication. Stage 0 of 4. Choose the evaluation point x = 5. Both operand paths refer to this same x.Left operand: xRight operand: xShared input xvalue = 5grad so far = —q = x × xvalue = —∂q/∂q = —
Forward: values →Backward: gradient contributions ←
Stage 0 of 4. Choose the evaluation point x = 5. Both operand paths refer to this same x.
Input used for this trace: x = 5.

The trace uses the multiplication edges from the article’s scalar engine. Changing x starts a new trace at that value.

Widżet rozpoczyna przejście wstecz z pochodną wyjścia względem siebie: dq/dq=1dq/dq=1. Każda ścieżka mnoży nadchodzący gradient przez swoją lokalną pochodną. W zwykłym Pythonie arytmetyka wygląda następująco:

x = 5.0
left, right = x, x
q = left * right             # forward: 25.0

output_grad = 1.0            # dq/dq
x_grad = 0.0
x_grad += output_grad * right  # left operand contributes 5.0
x_grad += output_grad * left   # right operand contributes 5.0
print(x_grad)                 # 10.0

Każda wartość potrzebna dla tej pochodnej pochodzi z obliczeń w x=5x=5. Użyliśmy reguły mnożenia i zapisanych operandów. Obliczanie f(5.001)f(5.001) należy do metody różnic skończonych w tabeli; powyższy ślad autodiff jest kompletny i obejmuje pojedynczy punkt oceny.

Automatyzacja polega na zapisaniu tych ścieżek argumentów i zastosowaniu dla nas ich reguł pochodnych. Później nasza klasa Value wykona te same obliczenia, gdy napiszemy q = x * x i wywołamy q.backward().

Znajomość operacji i zrozumienie kształtu

Załóżmy, że mamy tylko stół:

xxZaobserwowany wynik
11
24
39
…\ldots…\ldots
10100

Te skończone obserwacje nie określają jednoznacznie funkcji między wymienionymi punktami ani jej dokładnej pochodnej w wymienionym punkcie. Wiele funkcji może zgadzać się z każdym wierszem i mieć różne nachylenia. Jedną z opcji jest obliczenie szacunkowej różnicy skończonej na podstawie dostępnych pobliskich próbek.

Dzięki programowi return x * x mamy więcej informacji: znamy operacje generujące wynik, więc możemy zastosować ich reguły pochodne. Sieć neuronowa również zna obliczenia dotyczące swoich przewidywań i strat. Przykłady szkoleniowe mogą mieć formę tabeli, ale model i strata są wykonywalnymi funkcjami matematycznymi.

Znajomość tych operacji również nie daje nam automatycznie zrozumienia całej krzywej. Rozważ:

g(x)=sin⁡(x2)+0.2x2,g′(x)=2xcos⁡(x2)+0.4x.g(x)=\sin(x^2)+0.2x^2, \qquad g'(x)=2x\cos(x^2)+0.4x.

Za pomocą tych wzorów możemy wyznaczyć wartość i pochodną w dowolnym wybranym punkcie. Czy potrafisz sobie wyobrazić każdy wzrost, spadek i punkt zwrotny po prostu na podstawie ich przeczytania? Przesuń Known evaluation point x poniżej, a następnie wybierz Reveal full curve. Wartość lokalna i nachylenie są dostępne, zanim cały wykres będzie widoczny.

Experiment 2 · local slope and global shapeCompute a slope before revealing the curve
g(x) = sin(x²) + 0.2x²
g(x) against xAt x=1, g(x)=1.0415 and its derivative is 1.4806. Only the selected point and its tangent are visible.-4-1-20.50223.545g(x)x
Known operations: x² = 1 → sin(x²) = 0.8415 → add 0.2x² = 0.2.
g(x) = 1.0415 · g′(x) = 2x cos(x²) + 0.4x = 1.4806

The value and derivative are computed from known rules at the selected point. Revealing the curve additionally samples the function across the displayed interval.

Wzór całkowicie określa funkcję. Zrozumienie jego globalnej geometrii wymaga dodatkowej oceny lub analizy. Wykres daje nam więcej informacji w wybranym przedziale, podczas gdy pochodna opisuje lokalną wrażliwość w jednym punkcie.

W przypadku sieci neuronowej strata może zależeć od milionów wag i odchyleń. Znamy program obliczający L(w1,w2,…)L(w_1,w_2,\ldots) przy wybranym ustawieniu parametrów. Jego gradient nadaje tam lokalną wrażliwość. Obliczenia te nie pozwalają zlokalizować każdego minimum w innym miejscu tej wielowymiarowej przestrzeni. Trening wykorzystuje informacje lokalne do wyboru aktualizacji, a następnie oblicza nowy gradient przy zaktualizowanych parametrach.

Użyj pobliskich punktów, aby zwizualizować nachylenie

Wróćmy teraz do f(x)=x2f(x)=x^2, aby zapoznać się z pierwszym z dwóch zastosowań skończonych różnic: budowanie intuicji nachylenia. Obliczyliśmy już jego pochodną poprzez funkcję autodiff. Ten eksperyment ilustruje, co ta pochodna oznacza geometrycznie.

Porównaj f(5)=25f(5)=25 i f(5.001)=25.010001f(5.001)=25.010001. Ich średnie nachylenie wynosi:

f(5.001)−f(5)0.001=10.001.\frac{f(5.001)-f(5)}{0.001}=10.001.

W przypadku ogólnej niezerowej zmiany wejścia hh:

f(x+h)−f(x)h=(x+h)2−x2h=2x+h.\frac{f(x+h)-f(x)}{h} =\frac{(x+h)^2-x^2}{h} =2x+h.

Gdy hh zbliża się do zera, stosunek ten zbliża się do pochodnej 2x2x. W x=5x=5 oszacowanie różnicy skończonej zbliża się do 1010.

Zmniejsz Small change h do 0.0010.001 i obserwuj, jak pomarańczowa sieczna zbliża się do fioletowej stycznej. Następnie przesuń Position x na wartość ujemną: nachylenie ujemne oznacza, że ​​niewielki wzrost sygnału wejściowego powoduje zmniejszenie sygnału wyjściowego.

Experiment 3 · finite-difference intuitionNearby values illustrate the slope
f(x) = x² against xAt x=5, the tangent slope is 10. With h=0.8, the secant slope is 10.8.-60-310020330640f(x) = x²x
CurveTangent: exact derivativeSecant: finite difference
(f(x + h) − f(x)) / h = 10.8
f′(x) = 2x = 10

A small increase in x increases f(x). The finite-difference error is h = 0.8.

Purpose: visualize the meaning of a derivative. The training experiments compute gradients with automatic differentiation.

To obliczenie pobliskiego punktu jest skończoną różnicą. Później wykorzystamy kolejną skończoną różnicę do niezależnej weryfikacji silnika. W eksperymentach szkoleniowych gradienty pochodzą z zarejestrowanych operacji i ich lokalnych reguł pochodnych.

W przypadku x2x^2 dokładna zmiana wyjścia wynosi 2xΔx+(Δx)22x\Delta x+(\Delta x)^2. Pochodna obejmuje część pierwszego rzędu; nie przewiduje dokładnie dowolnych skończonych zmian.

Prognoza daje nam coś do oceny

Załóżmy teraz, że model otrzymuje dane wejściowe x=2x=2 i powinien przewidzieć docelowy y=5y=5. Nadaj mu jedną wagę ww i jedno odchylenie bb:

y^=wx+b.\hat y=wx+b.

Kapelusz w y^\hat y oznacza przepowiednię. Przy w=1w=1 i b=0b=0 prognoza to 22, czyli o 33 poniżej wartości docelowej.

Co powinien minimalizować trening? Minimalizowanie samej prognozy obniżyłoby ją, mimo że potrzebujemy jej wzrostu. Potrzebujemy funkcji, która mierzy rozbieżność między przewidywaniem a celem.

Wybierz błąd kwadratowy:

L=(y^−y)2.L=(\hat y-y)^2.

Obecna strata wynosi (2−5)2=9(2-5)^2=9. Zero oznaczałoby, że przewidywanie odpowiada celowi w tym przykładzie.

Zastąp obliczenia modelu stratą:

L(w,b)=(wx+b−y)2.\boxed{L(w,b)=(wx+b-y)^2.}

To jest połączenie, którego potrzebujemy. Strata jest teraz funkcją parametrów, które możemy zmienić. Dla ustalonego przykładu szkoleniowego (x,y)=(2,5)(x,y)=(2,5):

L(w,b)=(2w+b−5)2.L(w,b)=(2w+b-5)^2.

Sama liczba 99 nie mówi nam, w którą stronę mamy się poruszać. W przypadku b=0b=0 zarówno w=1w=1, jak i w=4w=4 powodują stratę 99, ale muszą poruszać się w przeciwnych kierunkach. Rozróżniamy funkcję, która spowodowała stratę, ocenianą przy bieżących parametrach.

To samo obliczenie, z wagą jako zmienną

Przytrzymaj wejście treningowe x=2x=2, cel y=5y=5 i stałe odchylenie b=0b=0. Strata staje się zwykłą funkcją jednej zmiennej:

L(w)=(2w−5)2=4w2−20w+25.L(w)=(2w-5)^2=4w^2-20w+25.

Możemy ręcznie rozróżnić to małe wyrażenie i zastąpić aktualną wagę, dokładnie tak, jak zrobiliśmy to w przypadku x2x^2:

KrokZnana funkcjaStrata modelu przy stałych danych treningowych
Napisz funkcjęf(t)=t2f(t)=t^2L(w)=(2w−5)2L(w)=(2w-5)^2
Różnicujf′(t)=2tf'(t)=2tL′(w)=8w−20L'(w)=8w-20
Wstaw aktualną wartośćt=5t=5w=1w=1
Uzyskaj pochodnąf′(5)=10f'(5)=10L′(1)=−12L'(1)=-12

Litera nazywająca zmienną została zmieniona z tt na ww. Dane wejściowe i cel szkolenia to stałe liczby w L(w)L(w); pytamy, jak strata reaguje na wagę. To samo rozróżnienie ma zastosowanie, gdy używamy całej partii: dane wejściowe i cele partii pozostają stałe, podczas gdy różnicujemy pod względem parametrów.

Model ten jest na tyle mały, że można go rozwinąć do wielomianu. W przypadku większej sieci funkcja autodiff rejestruje operacje do przodu przy bieżącym ustawieniu parametrów i ocenia ich lokalne reguły pochodne wstecz. Oblicza wartości liczbowe pochodnych parametrów bez uprzedniego konstruowania jednego rozszerzonego wzoru symbolicznego dla gradientu sieci.

Którą pochodną weźmiemy?

Na wagę chcemy:

∂L∂w.\frac{\partial L}{\partial w}.

Przeczytaj to w następujący sposób: jak szybko zmieniłaby się strata, gdybyśmy zmienili wagę, utrzymując inne niezależne wielkości na stałym poziomie? Używa się symbolu ∂\partial, ponieważ LL zależy od kilku zmiennych. W przypadku tych obliczeń dane wejściowe, cel i odchylenie pozostają stałe.

Model i strata tworzą kompozycję. Najpierw oblicz przewidywanie, następnie odejmij wartość docelową i podnieś błąd do kwadratu:

w ⟶ y^=wx+b ⟶ e=y^−y ⟶ L=e2.w\ \longrightarrow\ \hat y=wx+b \ \longrightarrow\ e=\hat y-y \ \longrightarrow\ L=e^2.

Każda operacja ma lokalną pochodną:

OperacjaJakie zmiany?Lokalna pochodna
y^=wx+b\hat y=wx+bZmień ww, przytrzymaj x,bx,b nieruchomo∂y^/∂w=x\partial\hat y/\partial w=x
e=y^−ye=\hat y-yZmień y^\hat y, przytrzymaj yy nieruchomo∂e/∂y^=1\partial e/\partial\hat y=1
L=e2L=e^2Zmień eedL/de=2edL/de=2e

Reguła łańcucha mnoży te wrażliwości wzdłuż ścieżki:

∂L∂w=dLde∂e∂y^∂y^∂w=2e⋅1⋅x=2(y^−y)x.\frac{\partial L}{\partial w} =\frac{dL}{de} \frac{\partial e}{\partial\hat y} \frac{\partial\hat y}{\partial w} =2e\cdot1\cdot x =2(\hat y-y)x.

Przy naszych wartościach wyjściowych e=−3e=-3 i x=2x=2:

∂L∂w=2(−3)(2)=−12.\frac{\partial L}{\partial w}=2(-3)(2)=-12.

Zwiększenie masy 0.010.01 powinno zatem zmienić stratę o około −12×0.01=−0.12-12\times0.01=-0.12. Dokładna nowa strata to (2.02−5)2=8.8804(2.02-5)^2=8.8804, zmiana −0.1196-0.1196. Zgodność poprawia się w miarę zmniejszania się kroku.

W przypadku błędu zmienia się ostatni czynnik: ∂y^/∂b=1\partial\hat y/\partial b=1. A więc:

∂L∂b=2(y^−y)=−6.\frac{\partial L}{\partial b}=2(\hat y-y)=-6.

Pochodne w odniesieniu do wszystkich możliwych do wytrenowania parametrów tworzą razem gradient. Tutaj tym wektorem jest ∇L=(−12,−6)\nabla L=(-12,-6), w kolejności (w,b)(w,b).

Wybór straty zmienia sygnał uczenia

Model mówi nam, jak jego przewidywania reagują na wagę. Strata mówi nam, jak bardzo zmiana przewidywania ma znaczenie dla celu.

Aby uzyskać tę samą prognozę, porównaj dwie straty:

StrataPochodna ze względu na przewidywaniePochodna po wadze
Błąd kwadratowy: (y^−y)2(\hat y-y)^22(y^−y)2(\hat y-y)2(y^−y)x2(\hat y-y)x
Błąd bezwzględny: ∣y^−y∣\lvert\hat y-y\rvertsign⁡(y^−y)\operatorname{sign}(\hat y-y), od zerasign⁡(y^−y)x\operatorname{sign}(\hat y-y)x, od zera

W naszym punkcie wyjścia gradient predykcji wynosi −6-6 dla błędu kwadratowego i −1-1 dla błędu bezwzględnego. Mnożenie przez tę samą pochodną modelu, x=2x=2, daje gradienty wagowe −12-12 i −2-2.

Przełącz pomiędzy Squared error i Absolute error poniżej. Poruszaj Weight w, obserwując zarówno gradient predykcji, jak i gradient ciężaru. Dane wejściowe pozostają stałe, więc zmienia się tylko pierwszy czynnik.

Experiment 4The loss changes the slope
Loss L(w) against weight wInput x=2, target y=5, bias b=0. Prediction 2, loss 9, weight gradient -12.-100.7513.752.527.54.2541.25655Loss L(w)weight w
Current weightLocal slopeAfter the proposed step
ŷ = 2 × 1 = 2 · target = 5 · L = 9
∂L/∂ŷ = -6 · ∂ŷ/∂w = 2 · ∂L/∂w = -12
w next = 1 − 0.1 × (-12) = 2.2 · next loss = 0.36

x = 2, y = 5, and b = 0 stay fixed. This experiment updates only w.

Błąd kwadratowy daje większy gradient przewidywania, gdy wielkość błędu jest większa. Błąd bezwzględny ma stałą wielkość gradientu w odniesieniu do przewidywania, gdy błąd jest różny od zera. Jego pochodna jest nieokreślona przy zera; widget wybiera tam podgradient zera.

Żadna strata nie dostarcza reguł różnicowania. Zasady te pochodzą z rachunku różniczkowego. Wybór strat określa funkcję, na którą działają te reguły, a co za tym idzie, sygnał początkowy propagowany w modelu.

Instrument pochodny staje się aktualizacją

Zejście gradientowe odejmuje gradient skalowany:

wnew=w−η∂L∂w.w_{\text{new}}=w-\eta\frac{\partial L}{\partial w}.

Szybkość uczenia η\eta określa wielkość kroku. Z błędem kwadratowym, w=1w=1 i η=0.1\eta=0.1:

wnew=1−0.1(−12)=2.2.w_{\text{new}}=1-0.1(-12)=2.2.

Utrzymując odchylenie na poziomie zerowym, nowa prognoza to 4.44.4, a nowa strata to 0.360.36. Użyj Reset i Take one step w eksperymencie ze stratami, aby odtworzyć to obliczenie.

Tutaj zaktualizowaliśmy tylko wagę. Jeśli zaktualizujemy również odchylenie z tego samego gradientu, stanie się ono 0.60.6, dając prognozę 2.2×2+0.6=52.2\times2+0.6=5 i stratę zerową dla tego pojedynczego przykładu. Są to różne aktualizacje, więc dają różne wyniki.

Ujemna pochodna sugeruje zwiększenie parametru; dodatnia pochodna sugeruje jej zmniejszenie. To jest lokalne oświadczenie. Duży krok może przekroczyć minimum i zwiększyć stratę. Propagacja wsteczna oblicza gradient; Optymalizator i jego szybkość uczenia się określają, co z nim zrobić.

Propagacja wsteczna to reguła łańcuchowa przechodząca przez wykres

Obliczenia możemy zapisać w postaci wykresu. W każdym pudełku znajduje się liczba, a każda operacja łączy swój wynik z wartościami użytymi do jego wygenerowania.

Zacznij od skrajnego prawego pola. Pochodna straty względem samej siebie jest równa:

∂L∂L=1.\frac{\partial L}{\partial L}=1.

Następnie pracuj do tyłu. Pudełko otrzymuje gradient informujący nas, jak ostateczna strata reaguje na wartość tego pola. Aby przekazać wkład do jednego z wejść, pomnóż przez lokalną pochodną operacji:

input gradient contribution=output gradient×local derivative.\boxed{\text{input gradient contribution} =\text{output gradient}\times\text{local derivative}.}

Wybierz Seed gradient, następnie Backpropagate one operation, aż dojdziesz do ww i xx. Każdy wyświetlany gradient jest pochodną końcowej straty, nawet jeśli ramka przedstawia wynik pośredni.

Experiment 5Follow the loss backward
wvalue 1grad —xvalue 2grad —p = w × xvalue 2grad —bvalue 0grad —ŷ = p + bvalue 2grad —y (target)value 5grad —e = ŷ − yvalue -3grad —L = e²value 9grad —Values flow → · apply local derivative rules ←

Scroll the graph sideways to follow all the operations.

Stage 0 of 5. Forward pass: evaluate the operations from left to right. Each box stores its value. Gradients have not been seeded yet.
Gradients revealed so far. “grad” means ∂L / ∂(box value).
ValueForward valueGradient
w1—
x2—
p = w × x2—
b0—
ŷ = p + b2—
y (target)5—
e = ŷ − y-3—
L = e²9—

Wykres przedstawia również pochodne w odniesieniu do wartości wejściowej i docelowej. To nie sprawia, że ​​nadają się do szkolenia. Wybieramy, które wartości aktualizuje optymalizator: tutaj tylko ww i bb. W głębszej sieci „wejście” warstwy może być wyjściem poprzedniej warstwy, więc jej gradient staje się drogą powrotną do wcześniejszych parametrów.

Ważne rozróżnienie pojawia się, gdy wartość ma wiele ścieżek prowadzących do straty. Wkłady z tych ścieżek dodaj. Na przykład:

q=a⋅a+a⟹dqda=a+a+1=2a+1.q=a\cdot a+a \quad\Longrightarrow\quad \frac{dq}{da}=a+a+1=2a+1.

W przypadku a=3a=3 tą pochodną jest 77. Przejście wstecz musi zachować wszystkie trzy wkłady, w tym oba użycia aa w mnożeniu. Dlatego nasz silnik będzie używać += podczas propagacji gradientów.

Zbuduj liczbę, która pamięta obliczenia

Zwykły float Pythona przechowuje wartość. Nasz Value będzie również przechowywać:

  • grad: pochodna wybranego wyjścia końcowego w odniesieniu do tej wartości.
  • edges: sekwencja par (parent, local_derivative) rejestrująca sposób obliczenia tej wartości.

Dodawanie i mnożenie stają się operacjami tworzenia grafów. W przypadku z=a+bz=a+b obie pochodne krawędziowe to 11. W przypadku z=abz=ab są to bb i aa.

Oto pierwsza część silnika, zawierająca potęgi całkowite i aktywację, z której skorzystamy później:

import math


class Value:
    def __init__(self, data, edges=()):
        self.data = float(data)
        self.grad = 0.0
        self.edges = tuple(edges)  # (parent, local derivative) pairs

    @staticmethod
    def wrap(value):
        return value if isinstance(value, Value) else Value(value)

    def __add__(self, other):
        other = Value.wrap(other)
        return Value(self.data + other.data, ((self, 1.0), (other, 1.0)))

    def __mul__(self, other):
        other = Value.wrap(other)
        return Value(self.data * other.data,
                     ((self, other.data), (other, self.data)))

    def __pow__(self, exponent):
        if not isinstance(exponent, int) or exponent < 1:
            raise ValueError("This engine supports positive integer powers only")
        return Value(self.data ** exponent,
                     ((self, exponent * self.data ** (exponent - 1)),))

    def tanh(self):
        output = math.tanh(self.data)
        return Value(output, ((self, 1.0 - output * output),))

Kiedy mnożymy dwa obiekty Value, __mul__ natychmiast oblicza wynik i zapisuje dwie krawędzie z ich lokalnymi pochodnymi. Te wartości pochodnych są oceniane przy użyciu bieżących danych operandów. Nic się jeszcze nie nauczyło; rejestrujemy obliczenia w przód.

Realizujemy potęgi bezpośrednio, więc (prediction - target) ** 2 rejestruje operację kwadratową. Silnik celowo obsługuje tylko dodatnie potęgi całkowite. Obejmuje to nasze przykłady bez wprowadzania dziedzin takich jak potęgi ułamkowe liczb ujemnych.

Tangens hiperboliczny, tanh, jest gładką funkcją aktywacji z pochodną 1−tanh⁡2(z)1-\tanh^2(z). Pozwala sieci wyrażać relacje nieliniowe.

Wygodne operatory Pythona sprawiają, że korzystanie z silnika jest przyjemne. Operatory odwrotne umożliwiają wyrażenia takie jak 2 * w i sum(...):

    def __neg__(self):
        return self * -1.0

    def __sub__(self, other):
        return self + -Value.wrap(other)

    def __rsub__(self, other):
        return Value.wrap(other) + -self

    __radd__ = __add__
    __rmul__ = __mul__

Wszystkie te metody należą do Value. complete Python file zawiera silnik, modele i przykłady wykonalności. Możesz także download tinygrad.py.

Odwiedź wykres w odpowiedniej kolejności

Aby obliczyć gradienty, musimy przetworzyć każdy wynik przed jego elementami nadrzędnymi, po otrzymaniu wszystkich dalszych składek tego wyniku.

Po pierwsze, przechodzenie w głąb odwiedza rodziców węzła przed dołączeniem samego węzła. Tworzy to porządek topologiczny. Zestaw seen gwarantuje, że węzeł współdzielony zostanie dodany tylko raz. Odwrócenie kolejności powoduje umieszczenie końcowego wyniku na pierwszym miejscu.

Dodaj tę metodę do Value:

    def backward(self):
        order, seen = [], set()

        def visit(node):
            if node in seen:
                return
            seen.add(node)
            for parent, _ in node.edges:
                visit(parent)
            order.append(node)

        visit(self)
        for node in order:
            node.grad = 0.0
        self.grad = 1.0
        for node in reversed(order):
            for parent, local_derivative in node.edges:
                parent.grad += node.grad * local_derivative

Istnieją trzy oddzielne kroki: usunięcie starych gradientów z osiągalnego wykresu, zaszczepienie gradientu końcowego wynikiem jednym i przejście wstecz w celu zgromadzenia składowych.

Zestaw seen deduplikuje węzły, podczas gdy edges zachowuje każde wystąpienie argumentu. W a * a dwie krawędzie wskazują na tego samego rodzica. Obydwa wykonują aktualizację +=. Zastąpienie edges zestawem elementów nadrzędnych bez zachowania odpowiednich wkładów spowodowałoby utratę tego rozróżnienia.

Nasz backward() celowo usuwa osiągalne gradienty podczas każdego połączenia. Dzięki temu powtarzalne wywołania tego samego niezmienionego wykresu są powtarzalne. Różni się od interfejsów API, które gromadzą gradienty liści podczas wywołań wstecznych i wymagają osobnego kroku zerowania. W ramach jednego przejścia wstecz wkłady ze wszystkich ścieżek wykresu są nadal kumulowane.

Ta implementacja zapisuje numeryczne lokalne pochodne podczas przebiegu w przód. Zmień dane parametrów po wstecz, a następnie utwórz nowy wykres dla następnej iteracji. Ponowne uruchomienie starego wykresu po zmianie danych spowodowałoby użycie nieaktualnych lokalnych instrumentów pochodnych.

Rekursywne przechodzenie po grafie sprawia, że ten silnik uczący jest krótki. Długie łańcuchy obliczeniowe, takie jak straty zsumowane w tysiącach przykładów, mogą przekroczyć limit rekurencji Pythona; iteracyjne przechodzenie pozwala uniknąć tego ograniczenia.

Sprawdź silnik pod kątem arytmetycznym

Zapisz cały plik jako tinygrad.py. W innym pliku Pythona w tym samym katalogu:

from tinygrad import Value

w, b = Value(1), Value(0)
prediction = w * 2 + b
loss = (prediction - 5) ** 2
loss.backward()

print(loss.data)  # 9.0
print(w.grad)     # -12.0
print(b.grad)     # -6.0

a = Value(3)
q = a * a + a
q.backward()
print(a.grad)    # 7.0: all three paths contribute

Silnik nigdy nie otrzymał specjalnego wzoru na gradient masy tego modelu. Rejestrował mnożenie, dodawanie, odejmowanie i kwadrat, a następnie łączył ich lokalne pochodne.

Użyj różnic skończonych, aby sprawdzić wynik

To drugi cel ogłoszony na wstępie: niezależna weryfikacja. Silnik uzyskał już w.grad z zarejestrowanych operacji w w=1w=1. Aby sprawdzić jego realizację, oszacuj stratę osobno po obu stronach tego ciężaru:

def numeric_loss(weight):
    return (weight * 2 - 5) ** 2

h = 1e-5
finite_difference = (numeric_loss(1 + h) - numeric_loss(1 - h)) / (2 * h)
assert abs(w.grad - finite_difference) < 1e-6

Ta centralna różnica jest kontrolą numeryczną, a nie algorytmem różnicowania silnika. Sprawdzenie każdego parametru w ten sposób wymaga dwóch ocen każdego parametru. Zamiast tego automatyczne różnicowanie w trybie odwrotnym ponownie wykorzystuje pojedynczy zarejestrowany wykres do obliczenia gradientów wszystkich parametrów dla wyjścia skalarnego, przy pracy proporcjonalnej do rozmiaru wykresu.

Niezwykle małe wartości hh ostatecznie wprowadzają anulowanie zmiennoprzecinkowe, więc mniejsze nie zawsze są lepsze w przypadku tej kontroli numerycznej. Poniższe pętle szkoleniowe uzyskują swoje gradienty poprzez wywołanie backward() na bieżącym wykresie.

Ucz się na kilku przykładach

Jeden przykład szkoleniowy nie może określić unikalnej linii: wiele par (w,b)(w,b) spełnia 2w+b=52w+b=5. Dodaj więcej przykładów:

Wprowadź xxCel yy
01
13
25
37

Punkty te leżą na y=2x+1y=2x+1, więc możemy sprawdzić, czy trening znajdzie w≈2w\approx2 i b≈1b\approx1.

Zdefiniuj stratę jako średnią czterech kwadratów błędów:

L(w,b)=14∑i=14(wxi+b−yi)2.L(w,b)=\frac14\sum_{i=1}^{4}(wx_i+b-y_i)^2.

Wspólna waga pojawia się we wszystkich czterech prognozach. Podczas odtwarzania wstecz każda ścieżka przyczynia się do tego samego w.grad. Mnożenie przez 1/41/4 skaluje wszystkie składki tak, aby odpowiadały średniej stracie. Sumowanie zamiast uśredniania pomnożyłoby oba gradienty przez cztery.

Użyj Step, aby sprawdzić jedną aktualizację, a następnie Run 100 steps. Wypróbuj współczynnik uczenia 0.30.3 ze stanu resetu, aby zobaczyć wzrost straty, mimo że gradienty są obliczone poprawnie.

Experiment 6Use the gradients to learn a line
prediction ŷ against input xAfter 0 updates, w=-1, b=0, mean squared error=41.5. The four targets lie on y=2x+1.-0.3-40.6-0.751.52.52.45.753.39prediction ŷinput x
Model: ŷ = wx + bTargetsPrediction errors
Step 0 · w = -1 · b = 0 · MSE = 41.5
log₁₀(MSE) against training stepLoss history on a logarithmic scale. A value of −3 means MSE 0.001. Losses below 10⁻¹² are plotted at −12.0-32.5-1.755-0.57.50.75102log₁₀(MSE)training step

Each click builds a fresh graph, averages four squared errors, runs backward(), and updates both w and b. The line plot rescales if the parameters grow; the loss plot uses log₁₀, with a floor of 10⁻¹².

Widget korzysta z JavaScriptowego odpowiednika powyższego silnika skalarnego. Jego gradienty są obliczane na podstawie nowego wykresu na każdym kroku. Oto ta sama pętla treningowa w Pythonie:

def train_line(steps=1000, learning_rate=0.05):
    data = [(0, 1), (1, 3), (2, 5), (3, 7)]
    w, b = Value(-1), Value(0)
    for _ in range(steps):
        loss = sum((w * x + b - y) ** 2 for x, y in data) * (1 / len(data))
        loss.backward()  # also clears this graph's old gradients
        for parameter in (w, b):
            parameter.data -= learning_rate * parameter.grad
    final_loss = sum((w.data * x + b.data - y) ** 2 for x, y in data) / len(data)
    return w.data, b.data, final_loss

Obydwa gradienty pochodzą z tego samego przejścia do przodu przed zmianą któregokolwiek z parametrów. Następna iteracja ponownie oblicza prognozy, straty i instrumenty pochodne przy zaktualizowanych parametrach. Zwrócona strata jest szacowana po ostatniej aktualizacji, więc opisuje zwrócone wagi.

Użyj tego samego silnika dla sieci neuronowej

Do tej pory model był pokrewny. Mała sieć neuronowa nie potrzebuje nowych maszyn różnicujących: jej przebieg w przód stanowi większą kompozycję operacji, które już obsługujemy.

Neuron mnoży dane wejściowe przez wagi, dodaje obciążenie i opcjonalnie stosuje tanh. MLP łączy warstwy neuronów. W tym przykładzie regresji ostatnią warstwę utrzymujemy liniowo:

import random


class Neuron:
    def __init__(self, n_inputs, rng, nonlinear=True):
        self.weights = [Value(rng.uniform(-1, 1)) for _ in range(n_inputs)]
        self.bias = Value(0)
        self.nonlinear = nonlinear

    def __call__(self, inputs):
        total = sum((w * x for w, x in zip(self.weights, inputs)), self.bias)
        return total.tanh() if self.nonlinear else total

    def parameters(self):
        return self.weights + [self.bias]


class MLP:
    def __init__(self, sizes, seed=42):
        rng = random.Random(seed)
        self.layers = [
            [Neuron(n_in, rng, nonlinear=i < len(sizes) - 2)
             for _ in range(n_out)]
            for i, (n_in, n_out) in enumerate(zip(sizes, sizes[1:]))
        ]

    def __call__(self, inputs):
        for layer in self.layers:
            inputs = [neuron(inputs) for neuron in layer]
        return inputs

    def parameters(self):
        return [p for layer in self.layers for neuron in layer
                for p in neuron.parameters()]

MLP([2, 4, 1]) tworzy dwa wejścia, cztery ukryte neurony za pomocą tanh i jedno wyjście liniowe. Jego parametry to 8 ukrytych wag, 4 ukryte obciążenia, 4 wagi wyjściowe i 1 obciążenie wyjściowe: 17 możliwych do wyszkolenia skalarów.

Trenuj go na XOR, kodując równe wejścia jako −1-1 i różne wejścia jako +1+1. Żadna funkcja afiniczna dwóch wejść nie może dokładnie pasować do wszystkich czterech celów; ukryte aktywacje nieliniowe pozwalają modelowi je dopasować.

def train_xor(steps=1000, learning_rate=0.1):
    # Same inputs twice equal -1; different inputs equal +1.
    examples = [([0, 0], -1), ([0, 1], 1), ([1, 0], 1), ([1, 1], -1)]
    model = MLP([2, 4, 1], seed=42)
    parameters = model.parameters()
    for _ in range(steps):
        loss = sum((model(x)[0] - y) ** 2 for x, y in examples) * 0.25
        loss.backward()
        for parameter in parameters:
            parameter.data -= learning_rate * parameter.grad
    predictions = [model(x)[0].data for x, _ in examples]
    final_loss = sum((p - y) ** 2 for p, (_, y) in zip(predictions, examples)) / 4
    return predictions, final_loss

Struktura uczenia pozostaje niezmieniona: zbuduj prognozy i stratę skalarną, wywołaj backward() i zaktualizuj parametry. Silnik widzi operacje i krawędzie. Nie musi wiedzieć, które operacje należą do neuronu lub warstwy.

Uruchom cały plik:

python3 tinygrad.py

Przy dostarczonym ziarnie, 1000 krokach i szybkości uczenia się 0.10.1, przewidywania XOR zaokrąglają się do [-1.0, 1.0, 1.0, -1.0] z czterema miejscami po przecinku. Jest to mała demonstracja ze stałymi danymi i inicjalizacją, a nie gwarancja dla dowolnych sieci lub szybkości uczenia się.

Do czego przyczyniła się strata

Zaczęliśmy od pochodnej x2x^2, która nie wymagała pojęcia modelu ani szkolenia. Następnie wybraliśmy funkcję predykcyjną i dołączyliśmy stratę. Podstawienie uczyniło stratę funkcją parametrów, dając nam precyzyjne pytanie: jak zmieniłby się ten cel, gdyby zmienił się jeden parametr?

Strata określa cel i jego wrażliwość na prognozę. Model dostarcza ścieżki od parametrów do tej prognozy. Propagacja wsteczna łączy lokalne pochodne wzdłuż tych ścieżek, dodając elementy w miejscach, gdzie ścieżki się spotykają. Zejście gradientowe wykorzystuje powstałe gradienty parametrów do wybrania aktualizacji.

W przypadku modelu zbudowanego z operacji z użytecznymi regułami pochodnymi, strata skalarna daje naszemu silnikowi różniczkowemu wynik, od którego można zacząć, i cel, który optymalizator ma zredukować.

Aby zapoznać się z szerszym opisem sieci neuronowych, zobacz Jak uczą się sieci neuronowe: propagacja wsteczna i opadanie gradientu. Aby zapoznać się z uzupełniającą implementacją, która dołącza funkcje wsteczne do operacji, zobacz Silnik mikrogradowy Karpathy’ego i jego Sieci neuronowe: materiały od zera do bohatera.

Podgląd obrazu

Zbliżaj palcami lub użyj + / −. Przeciągaj lub użyj strzałek. Esc zamyka podgląd.