Perceptron i MLP

Jan Michorek

Wprowadzenie do teorii sieci neuronowych

Sieći neuronowe (NN) to złożone i wielowarstwowe systemy obliczeniowe, powstają one poprzez połączenie podstawowych jednostek, które nazywamy neuronami. Opisywane przeze mnie rodzaje sieci bedą rozwinięciem ideii NN. Aby zrozumieć działanie sieci i jej zamysł, należy sie skupić na pojedyńczym jej elemencie.

Podstawowa jednostka obliczeniowa

System nerwowy złożony jest z sieci neuronów, które komunikują się miedzy sobą jednokierunkowo. Sygnał generowany przez jądro komórki jest wysyłany przez akson jednej komórki i podrożując przez synapse jest odbierany przez dendryty komórek następnych. Do przekazania impulsu dalej w sieć neuronów, zauważono że do ciała komórki musi zostać dostarczona sumarycznie ilość impulsów przekraczająca określoną wartość. Model matematyczny w literaturze został pokazany w 1943(McCulloch1943?). Koncepcja ta została rozwinięta o element uczenia przez Rosenblatta w 1958 roku. Architektura zakładała modyfikowalne wagi synaptyczne, co pozwala sztucznemu neuronowi na adaptacje. Neuron stał się podstawową jednostką obliczeniową, która przekształca wszystkie dochodządze do niego sygnały w jeden wyjściowy.

Dla współczesnego neuronu posiadającego \(n\) wejść, sygnał wyjściowy \(y\) opisany jest równaniem: \[\begin{equation} y = f\left(\sum_{i=1}^{n} w_i x_i + b\right) \end{equation}\]

  1. \(w_1\) to waga lącząca neuron poprzedniej warstwy.

  2. \(x_1\) wartość neuronu warstwy poprzedniej.

  3. \(b\) obciążenie.

  4. \(f\) funkcja aktywacji.

Model Perceptronu

Elementem wyróżniającym architekturę perceptronową jest zastosowana w nim dyskretna funkcja aktywacji \(\text{sgn}\). \[\begin{equation} \text{sgn}(x) = \begin{cases} 1 & \text{dla } x > 0 \\ 0 & \text{dla } x = 0 \\ -1 & \text{dla } x < 0 \end{cases} \end{equation}\]

Single neuron perceptron

Model sztucznego neuronu z dwoma wejściami i skokową funkcją aktywacji.

Na powyższym schemacie widać w praktyce wzór (1). W najprostszej postaci perceptronu, mamy doczynienia z jedym perceptronem, do którego dochodzą wejscia, poprzez wagi. W celu ułatwienia dalszego rozumienia tekstu przyjęta przezemnie nomenklatura wag wygląda następująco:

\(w_{ds}^l\)

Schemat połączeń pomiędzy dwiema warstwami sieci z wyróżnionymi wagami (\(w_{11}^1\)) oraz (\(w_{21}^1\)).

Bramka logiczna AND z wykorzystaniem perceptronu

Problem, który architektura perceptronu rozwiązuje, można przedstawić wizualnie w układzie współrzędnych. Celem takiego logicznego układu jest binarna klasyfikacja z pomocą funkcji aktywacji sgn. Obliczenia w przód mają na celu wyznaczenie prostej, zwaną granicą w układzie współrzędnych. Wydzielone zostają dwie klasy, powyżej tej granicy, perceptron jest aktywowany, na wyjscie zadaje jedynkę, poniżej zadaje minus jeden.

zapisanie problemu

Regułę działania perceptronu, łatwo zrozumieć na perceptronie stworzonym na kształt Bramki logicznej AND. Schemat na rysunku 1. odwzorowywuje tą właśnie architekturę. 2 wejścia i jedno wyjście. Oczekiwane wyniki w zależności od wartosci wejsciowych: \[\begin{equation} \left\{ \mathbf{p}_1 = \begin{bmatrix} 0 \\ 0 \end{bmatrix}, t_1 = -1 \right\} \quad \left\{ \mathbf{p}_2 = \begin{bmatrix} 0 \\ 1 \end{bmatrix}, t_2 = -1 \right\} \quad \left\{ \mathbf{p}_3 = \begin{bmatrix} 1 \\ 0 \end{bmatrix}, t_3 = -1 \right\} \quad \left\{ \mathbf{p}_4 = \begin{bmatrix} 1 \\ 1 \end{bmatrix}, t_4 = 1 \right\} . \end{equation}\] zgodnie z rysunkiem 1. wartość wyjściowa perceptronu:

\(a = \mathit{sgn}(w_{11}^1*p_1 + w_{12}^1*p_2 +b)\)

Celem jest odnalezienie takich wag i biasu, aby wyznaczona prosta graniczna poprawnie odzielała wartości aktywacji neuronu 1 i -1.

rozwiązanie w matlabie

P = [0, 0, 1, 1;
     0, 1, 0, 1];

W = [2, 2];
b = -3;

net_input = W * P + b;
a = sign(net_input); 

Wynikiem działania powyższego skrytpu jest wektor przewidywań wartości \(\bm{a} = [-1, -1, -1, 1]\)

Wizualizacja granicy decyzyjnej i przestrzeni cech dla bramki AND wygenerowana w środowisku MATLAB.

Reguła uczenia Perceptronu

Omawiana we wstępnie adaptacyjność wynika z możliwości odpowiedniego dobierania i modyfikowania wag perceptronu. W przykładzie bramki AND, proces nauki i modyfikacji tych zmian został celowo pominięty, a zastosowane w nim wagi były poprzednio dobrane. Reguła uczenia perceptronu, polega na aktualizacji wag w zależności od błędu, osiągnietęgo po kroku w przód. \[\begin{equation} e = t-a \end{equation}\] Funkcja aktywacji sgn, daje 3 możliwości wyniku: \[\begin{equation} e = -1 \end{equation}\] \[\begin{equation} e = 1 \end{equation}\] \[\begin{equation} e = 0 \end{equation}\] Dzięki czemu, możemy uogólnić zapis wzoru na nowe wagi w zależności od wynikowego błędu: \[\begin{equation} W_{new} = W_{old} + e p^T \end{equation}\]

W celu pokazania kroku aktualizacji wag, parametry początkowe zostaną zainicjowane zerami. \[\begin{equation} \bm{W}^{(0)} = \begin{bmatrix} 0 & 0 \end{bmatrix}, \quad b^{(0)} = 0 \end{equation}\]

Krok 1: Aktualizacja dla wzorca uczącego
Wyobraźmy sobie sytuację, w której na wejście sieci trafia punkt \(\bm{p}_1 = [1, 1]^T\). Dla bramki logicznej AND pożądaną odpowiedzią jest stan wysoki \(t_1 = 1\). Sieć oblicza wartość pobudzenia (krok w przód): \[\begin{equation} net = \bm{W}^{(0)}\bm{p}_1 + b^{(0)} = \begin{bmatrix} 0 & 0 \end{bmatrix} \begin{bmatrix} 1 \\ 1 \end{bmatrix} + 0 = 0 \end{equation}\] Ponieważ pobudzenie nie przekroczyło zera (\(net \le 0\)), funkcja aktywacji generuje na wyjściu sygnał \(a = 0\). Skutkuje to powstaniem dodatniego błędu predykcji: \[\begin{equation} e = t_1 - a = 1 - 0 = 1 \end{equation}\] Algorytm dokonuje modyfikacji parametrów (reguła delta), korygując macierz wag oraz obciążenie: \[\begin{equation} \bm{W}^{(1)} = \bm{W}^{(0)} + e\bm{p}_1^T = \begin{bmatrix} 0 & 0 \end{bmatrix} + (1)\begin{bmatrix} 1 & 1 \end{bmatrix} = \begin{bmatrix} 1 & 1 \end{bmatrix} \end{equation}\] \[\begin{equation} b^{(1)} = b^{(0)} + e = 0 + 1 = 1 \end{equation}\]

Powyższa procedura jest iterowana przez cały zbiór danych tak długo, aż sieć przestanie popełniać błędy. Zmiany w wektorze wag dążą do takiego ułożenia granicy decyzyjnej, która odseparuje punkt \([1,1]\) od pozostałych kombinacji.

Multi-Layer Perceptron (MLP) i Nieliniowość

Zaprezentowany w poprzednim rozdziale problem bramki logicznej AND jest problemem liniowo separowalnym, który prosta jedno lub wielo neuronowa sieć perceptronowa jest w stanie sobie poradzić. Problem narodził się, kiedy spróbowano zastosować perceptron do rozwiązania bramki logicznej XOR, którego nieliniowość uniemożliwiła rozwiązanie siecią perceptronową. Problem ten rozwiązano poprzez dodanie warstw ukrytych. Dodanie warstw ukrytych wymusiło zmianę funkcji aktywacji na nie liniową. W przeciwnym wypadku, dodanie warstwy ukrytej sprowadziło by się do bardziej skomplikowanej kombinacji liniowej, co nie rozwiązało by problemu. Dodanie nieliniowości i dodatkowych warstw, zdecydowanie utrudniło aktualizacje parametrów sieci. Reguły wstecznej propagacji błędu i aktualizacji wag zostały zdefiniowane 30 lat poźniej (rumelhart1986learning?).

Funkcja Aktywacji

W klasycznym Perceptronie wielowarstwowym używaną funckją aktywacji jest sigmoid. Funkcja ta przekształca wartości, tak aby znajdowały się w przedziale \((0,1)\). \[\begin{equation} \sigma(x) = \frac{1}{1+e^{-x}} \end{equation}\] Prosta pochodna funkcji sigmoid, ułatwia skomplikowane obliczenia wstecznej propagacji błedu, stąd jest częstym wyborem w architekturach sieci neuronowych. Pochodna prezentuje się następująco \[\begin{equation} \frac{d\sigma}{d x} = \sigma(x) * (1-\sigma(x)) \end{equation}\]

Funkcja Straty (Kosztu)

Aby możliwa była ocena wyników perceptronu wielowarstwowego, zdefiniowano funkcje straty. Jest to matematyczna miara błędu, często nazywana również funkcją kosztu. Występuje w wielu formach, ale zawsze jej zadaniem jest określenie jak bardzo przewidywania sieci \(\hat{y}\) różnią się od wartości oczekiwanych \(y\). W pierwszym perceptronie wielowarstwowym stosowaną miarą był bład średniokwadratowy (MSE). Dla wektora warstwy wyjściowej perceptronu funkcje straty E definiuje się następująco: \[\begin{equation} E = \frac{1}{2} \sum_{i=1}^{k} (y_i-\hat{y_i})^2 \end{equation}\] gdzie \(k\) to liczba neuronów w warstwie wyjściowej. Kwadrat różnicy gwarantuje, że błąd będzie dodatni i proporcjonalnie większy w przypadku dużej różnicy.

Przepływ Sygnału: Propagacja W Przód i Wsteczna

Proces uczenia perceptronu wielowarstwowego jest procedurą iteracyjną, w której każda z iteracji jest podzielona na 2 fazy. Fazę propagacji sygnału w przód i fazę wstecznej propagacji błędu.

Propagacja w przód (Forward Pass)

Faza propagacji w przód to proces podczas którego perceptron dokonuje predykcji na podstawie swoich parametrów. Transformacja sygnału wejściowego opiera się na serii obliczeń powtórzonej \(l-1\) razy gdzie \(l\) to liczba warst perceptronu. Pierwszą częścią obliczeń dla dowolnej warstwy \(l\): \[\begin{equation} \bm{z}^{(l)} = \bm{W}^{(l)}\bm{a}^{(l-1)} + \bm{b}^{(l)} \end{equation}\] gdzie \(\bm{W}^{(l)}\) to macierz wag połączeń wchodzących do warstwy \(l\), a \(\bm{b}^{(l)}\) to wektor (biasów). \(\bm{a}^{(0)}\) to sygnał doprowadzony do neuronu.

Następnie, aby wprowadzić do modelu nieliniowość, wektor sum ważonych przepuszczany jest przez funkcję aktywacji (np. opisaną wcześniej funkcję sigmoidalną). \[\begin{equation} \bm{a}^{(l)} = \sigma(\bm{z}^{(l)}) \end{equation}\] Wektor wyjściowy ostatniej warstwy sieci stanowi ostateczną predykcję modelu, oznaczaną jako \(\bm{\hat{y}}\).

Wsteczna Propagacja Błędu (Backpropagation)

W wyniku zakończenia propagacji w przód otrzymujemy, predykcję \(\hat{y}\), na podstawie której funkcja straty np. wspomniana wcześniej MSE wylicza bład całkowity E. Zadaniem algorytmu propagacji wstecznej, jest odpowiedź na pytanie: jak zmiana pojedyńczych warstw wpływa na końcowy błąd sieci. Formalnie celem algorytmu jest znalezienie gradientu funkcji E \(\nabla E\), czyli wektora kierunku w maksymalnego wzrostu funkcj. Jako, że funkcja straty \(E\) jest funkcja wielu zmiennych rozpisując kroku graidientu wykorzystujemy pochodną cząstkową \(\frac{\partial L}{\partial w_{ij}}\). Ponieważ błąd obliczany jest na końcu sieci, a elementy za niego odpowiedzialne, znajdują się w jej wnętrzu, do wyznaczania posczególnych elementów wektora gradientu, wykorzystuję się regułę łańcuchową. Wpływ na błąd wagi łączącej neuron \(j\) z neuronem \(i\) w warstwie \(l\) można zapisać w następujący sposób: \[\begin{equation} \frac{\partial E}{\partial w_{ij}^{(l)}} = \frac{\partial E}{\partial a_i^{(l)}} \cdot \frac{\partial a_i^{(l)}}{\partial z_i^{(l)}} \cdot \frac{\partial z_i^{(l)}}{\partial w_{ij}^{(l)}} \end{equation}\]

Każdy z tych czynników odnosi się do konkretnego równania zdefiniowanego we wcześniejszych krokach:

Jeśli oznaczymy dwa pierwsze elementy powyższego równania za pomocą (\(\delta_i^{(l)}\)) to ostateczny wzór na pochodną cząstkową względem konkretnej wagi przyjmuje taką postać. \[\begin{equation} \frac{\partial E}{\partial w_{ij}^{(l)}} = \delta_i^{(l)} \cdot a_j^{(l-1)} \end{equation}\] (\(\delta_i^{(l)}\)) to błąd lokalny sygnału warstwy \(l\) (Zurada1992?)

Mając tak obliczone błędy względem wag sieci, za pomocą odpowiedniego algorytmu optymalizacyjnego dokonywana jest aktualizacja wag, co przesuwa całą sieć w stronę minimum funkcji straty.

Przykład

Aby zilustrować mechanikę przepływu sygnału zdefiniowaną w poprzednich rozdziałach, przeanalizujmy konkretny przykład perceptronu wielowarstwowego. Przyjmijmy architekturę składającą się z warstwy wejściowej przyjmującej 9 cech, pojedynczej warstwy ukrytej z 4 neuronami oraz warstwy wyjściowej dokonującej klasyfikacji do 3 oddzielnych klas.

Architektura w pełni połączonej sieci MLP w topologii 9-4-3.

Sygnał wejsciowy to wektor 9-elementowy \(X\) \[\begin{equation} \bm{X}= [x_1, x_2, \dots, x_9]^T \end{equation}\] Wektor wartości oczekiwanych \[\begin{equation} \bm{Y}=\begin{bmatrix} 0&0&1 \end{bmatrix}^T \end{equation}\] Macierz wag pierwszej warstwy ma wymiary \(4\times9\) \[\begin{equation} \bm{W^{(1)}} = \begin{bmatrix} w_{11}^1 & w_{12}^1 & \dots & w_{19}^1 \\ w_{21}^1 & \dots & \dots & \dots\\ \dots & \dots & \dots & \dots \\ w_{41}^1 & \dots & \dots&w_{49}^1 \end{bmatrix} \end{equation}\]

Macierz wag drugiej warstwy ma wymiary \(3\times4\) \[\begin{equation} \bm{W^{(2)}} = \begin{bmatrix} w_{11}^2 & w_{12}^2 & \dots & w_{14}^2 \\ w_{21}^2 & \dots & \dots & \dots\\ w_{31}^2 & \dots & \dots&w_{34}^2 \end{bmatrix} \end{equation}\] Wektor predykcji \(\bm{\hat{Y}}\) jest 3-elementowy \[\begin{equation} \bm{X}= [\hat{y_1}, \hat{y_2}, \hat{y_3}]^T \end{equation}\]

przyklad na liczbach

Wartości wag muszą zostać losowo zainicjowane, jednowartosciowa macierz wag, może doprowadzić do nie optymalnego procesu uczenia się sieci. Macierze \(\bm{W^1}\) i \(\bm{W^2}\) zostały zainijowane w następujący sposób: \[\begin{equation} \bm{W}^{1} = \begin{bmatrix} 0.1 & 0.3 & -0.3 & -0.1 & 0.1 & 0.8 & 0.8 & 0.8 & 0.1 \\ 0.2 & 0.1 & -0.4 & 0.1 & 0.1 & 0.1 & 0.7 & 0.1 & -0.1 \\ -0.3 & 0.7 & 0.8 & 0.8 & -0.8 & -0.9 & 0.1 & 0.1 & -0.0 \\ -0.4 & 0.1 & 0.7 & 0.9 & 0.1 & 0.1 & 0.9 & 0.9 & 0.1 \end{bmatrix} \end{equation}\]

\[\begin{equation} \bm{W}^{2} = \begin{bmatrix} 0.3 & 0.7 & 0.1 & -0.1 \\ 0.1 & 0.4 & 0.8 & -0.4 \\ -0.2 & -0.9 & 0.9 & 0.6 \end{bmatrix} \end{equation}\]

Wektor sygnału wejściowego \(\bm{X}\) \[\begin{equation} \bm{X} = \begin{bmatrix} 1 & 1 & 1 &0 &1 &1 &1 &1 &1 \end{bmatrix} \end{equation}\]

Pierwszą transformacje sygnału można opisać równaniem: \[\begin{equation} z^1 = \bm{W}^1 \cdot \bm{X} =\begin{bmatrix} 2,7\\ 0,8\\ -0,3\\ 2,5 \end{bmatrix} \end{equation}\] Wektor wynikowy, to surowe wartości sumy neuronów, następnym krokiem jest przepuszczenie tych wyników przez nieliniową funkcję aktywacji np. sigmoid.

Należy pamiętać, że funkcje aktywacji stosujemy do każdego elementu wektora, co możemy zapisać w ten sposób: \[\begin{equation} \bm{a} = \sigma(\bm{Z}) = \begin{bmatrix} \sigma(z_1) \\ \sigma(z_2) \\ \vdots \\ \sigma(z_n) \end{bmatrix} \end{equation}\] Dla wektora \(Z^1\) wektor aktywacji warstwy ukrytej wygląda następująco: \[\begin{equation} \sigma(\bm{Z^{(1)}}) = \bm{A}^{(1)} = \begin{bmatrix} 0,9370 \\ 0,6900 \\ 0,4256 \\ 0,9241 \end{bmatrix} \end{equation}\]

Aby otrzymać wektor aktywacji warstwy wyjsciowej, kroki 28 i 30 musimy powtórzyć. \[\begin{equation} z^{(2)} = \bm{W}^{(2)} \cdot \bm{A} =\begin{bmatrix} 0,7142\\ 0,3405\\ 0,129 \end{bmatrix} \end{equation}\] \[\begin{equation} \sigma(\bm{Z^{(2)}) = \bm{Y}^{(2)} = \begin{bmatrix} 0,67 \\ 0,5843 \\ 0,5322 \\ \end{bmatrix} \end{equation}\]

Otrzymany wektor jest wektorem predykcji sieci. Ostatnim krokiem propagacji w przód jest obliczenie wektora błędu. W tym celu zostanie wykorzystana opisywana wcześniej funkcja błędu średniokwadratowego. Dla każdego z błędów jest obliczany z osobna.

\[\begin{equation} E = \frac{1}{2} (y_1 - \hat{y}_1)^2 \end{equation}\]

Reguła łańcuchowa

W celu większej klarowności propagacji wstecznej, równanie gradientu pierwszej wagi w warstwie ukryty został przedstawiony skalarnie: \[\begin{equation} \frac{\partial E}{\partial w_{11}^{(1)}} = \left( \sum_{k=1}^{3} \frac{\partial E}{\partial \hat{y}_k} \cdot \frac{\partial \hat{y}_k}{\partial z_k^{(2)}} \cdot \frac{\partial z_k^{(2)}}{\partial a_1^{(1)}} \right) \cdot \frac{\partial a_1^{(1)}}{\partial z_1^{(1)}} \cdot \frac{\partial z_1^{(1)}}{\partial w_{11}^{(1)}} \end{equation}\] \[\begin{equation} \frac{\partial E}{\partial w_{11}^{(1)}} = \left( \sum_{k=1}^{3} (\hat{y}_k-y_k) \cdot \hat{y}_k(1 - \hat{y}_k) \cdot w_{k1}^{(2)} \right) \cdot a_1^{(1)}(1 - a_1^{(1)}) \cdot x_1 \end{equation}\] Co po podstawieniu wartości daje wynik: \[\begin{equation} w_{11}^1=-0.0048 \end{equation}\] W ten sposób można wyliczyć pochodne funkcji straty w zależności od każdej z wag. Jednak liczenie tego skalarnie jest nieefektywne obliczeniowo, dlatego te operacje wykonywane są na macierzach wykorzystując do tego pojęcie błędu lokalnego(\(\bm{\delta}\)). Błąd lokalny dla warstwy 2 wygląda następująco. \[\begin{equation} \bm{\delta}^{(2)} = (\bm{y} - \bm{\hat{y}}) \odot \sigma'(\bm{z}^{(2)}) \end{equation}\]

Prawdziwy sens takiego rozwiązania, można zaobserwować przy obliczaniu delty dla warstwy ukrytej. Zamiast ręcznego sumowania błędów, wektor \(\bm{\delta}^{(1)}\) jest wynikiem mnożenia: \[\begin{equation} \bm{\delta}^{(1)} = \left( (\bm{W}^{(2)})^T \bm{\delta}^{(2)} \right) \odot \sigma'(\bm{z}^{(1)}) \end{equation}\]

Mając wyznaczone wektory błędów lokalnych dla każdej warstwy, macierze gradientów wyznacza się mnożąc wektor delty przez transponowany wektor sygnału wejściowego do danej warstwy \[\begin{equation} \nabla_{\bm{W}^{(2)}} E = \bm{\delta}^{(2)}\cdot (\bm{A}^{(1)})^T \end{equation}\] \[\begin{equation} \nabla_{\bm{W}^{(1)}} E = \bm{\delta}^{(1)}\cdot \bm{X}^T \end{equation}\] Wracając do przykładu wektor \(\bm{\delta}^{(2)}\) wygląda następująco \[\begin{equation} \bm{\delta}^{(2)} = \begin{bmatrix} -0,148\\ -0,142\\ 0,116 \end{bmatrix} \end{equation}\] wektor delty ukrytej \(\bm{\delta}^{(1)}\) \[\begin{equation} (\bm{W}^{(1)})^T \cdot \bm{\delta}^{(2)} \odot \sigma'(\bm{z}^{(1)}) = \begin{bmatrix} -0,0048\\ -0,0567\\ 0,0058\\ 0,0099 \end{bmatrix} \end{equation}\] Finalnie posiadając juz wszystkie delty możemy obliczyć gradienty wag gradient dla \(W^{(1)}\) \[\begin{equation} \nabla{\bm{W}^{(1)}} =\bm{\delta}^{(1)} \cdot\bm{W}^{(1)} \end{equation}\] \[\begin{equation} \begin{bmatrix} -0.0048 & -0.0048 & -0.0048 & 0 & -0.0048 & -0.0048 & -0.0048 & -0.0048 & -0.0048 \\ -0.0567 & -0.0567 & -0.0567 & 0 & -0.0567 & -0.0567 & -0.0567 & -0.0567 & -0.0567 \\ -0.0058 & -0.0058 & -0.0058 & 0 & -0.0058 & -0.0058 & -0.0058 & -0.0058 & -0.0058 \\ 0.0099 & 0.0099 & 0.0099 & 0 & 0.0099 & 0.0099 & 0.0099 & 0.0099 & 0.0099 \end{bmatrix} \end{equation}\] gradient dla \(W^{(2)}\) \[\begin{equation} \nabla{\bm{W}^{(2)}} =\bm{\delta}^{(2)} \cdot\bm{X} = \begin{bmatrix} -0.1388 & -0.1022 & -0.0630 & -0.1369 \\ -0.1330 & -0.0979 & -0.0604 & -0.1312 \\ 0.1091 & 0.0804 & 0.0496 & 0.1076 \end{bmatrix} \end{equation}\]

Aktualizacja wag

Z tak wyliczonym gradientem funkcji straty jesteśmy w stanie odpowiednio zaktualizować wagi. Należy jednak pamiętać o tym, że gradient to kierunek maksymalnego wzrostu funkcji, a rozchodzi się o minimalizację funkcji straty. Dlatego gradienty należy odjąć od macierzy wag. Do zmiany wag wykorzystuje się algorytm optymalizacyjny, który przyjmuje wiele form, a najprostsza z nich i ta, która zostanie zastosowana nazywa się SGD (Stochastic gradient descent). Zakłada, że po każdej iteracji wagi zostają zaktualizowane. W aktualizacji wag bierze udział parametr, nazywany learning rate. Ma on za zadanie zapobiegnięciu zbyt szybkich zmian, które mogły by prowadzić do pominięcia minimum, lub utknięcia w minimum lokalnym. Używając SGD i tak nie można mieć pewności czy trening nie skończy się w minimum lokalnym. Wzór na aktualizacje wag: \[\begin{equation} \bm{W}^{nowe} = \bm{W}^{stare} -\eta\nabla W^{stare} \end{equation}\] gdzie \(\eta\) to wspołczynnik uczenia, w naszym przykładzie dla uprosczenia został przyjęty \(\eta = 1\). Zgodnie z teorią, macierze wag po aktualizacji wyglądają następująco: \[\begin{equation} \bm{W}^{(1)} = \begin{bmatrix} 0.1048 & 0.3048 & -0.2952 & -0.1000 & 0.1048 & 0.8048 & 0.8048 & 0.8048 & 0.1048 \\ 0.2567 & 0.1567 & -0.3433 & 0.1000 & 0.1567 & 0.1567 & 0.7567 & 0.1567 & -0.0433 \\ -0.2942 & 0.7058 & 0.8058 & 0.8000 & -0.7942 & -0.8942 & 0.1058 & 0.1058 & 0.0058 \\ -0.4099 & 0.0901 & 0.6901 & 0.9000 & 0.0901 & 0.0901 & 0.8901 & 0.8901 & 0.0901 \end{bmatrix} \end{equation}\]

\[\begin{equation} \bm{W}^{(2)} = \begin{bmatrix} 0.4388 & 0.8022 & 0.1630 & 0.0369 \\ 0.2330 & 0.4979 & 0.8604 & -0.2688 \\ -0.3091 & -0.9804 & 0.8504 & 0.4924 \end{bmatrix} \end{equation}\]

Wyniki

Pojedyńcze prezjśie sygnału w przód i tył nie jest wystarczające do rozwiązania nieliniowego problemu klasyfikacji, dlatego w celu osiągnięcia pożądanych wyników pokazana procedura jest powtarzana iteracyjnie. Powyższe obliczenia umieszcza się w pętli, nazywanej pętlą treningową. Warto wprowadzić pojęcie epoki, która definiowana jest jako pełne przejście algorytmu uczącego przez zbiór danych treningowych. Wykres wartośći funkcji straty od epoki prezentuje się następująco:

Wizualizacja zależności błędu od epoki wygenerowana w środowisku MATLAB.

Z wykresu widać ze dopiero po 100 epokach przewidywania sieci, nie odbiegają diametralnie od warstości oczekiwanych. Stan macierzy \(\bm{W}^{(1)}\) i \(\bm{W}^{(2)}\) po 200 epokach: \[\begin{equation} \bm{W}^{(1)} = \begin{bmatrix} -0.4525 & 0.3004 & -0.8525 & -0.1000 & -0.4525 & 3.0396 & 0.2475 & 0.2475 & -0.4525 \\ -0.2722 & 4.0988 & -0.8722 & 0.1000 & -0.3722 & 1.4123 & 0.2278 & -0.3722 & -0.5722 \\ -0.1349 & 1.1933 & 0.9651 & 0.8000 & -0.6349 & -4.4999 & 0.2651 & 0.2651 & 0.1651 \\ -0.4185 & -3.5950 & 0.6815 & 0.9000 & 0.0815 & 1.3495 & 0.8815 & 0.8815 & 0.0815 \end{bmatrix} \end{equation}\]

\[\begin{equation} \bm{W}^{(2)} = \begin{bmatrix} 2.1091 & 1.5979 & -4.4859 & -1.0830 \\ -2.3557 & 1.4241 & 2.6968 & -4.5643 \\ -1.5030 & -4.3578 & 1.2332 & 2.4495 \end{bmatrix} \end{equation}\]

Wyniki klasyfikacji

Przewidywania sieci po wytrenowaniu, powstałe w tak zwanej fazie odtwarzania, nie wyliczamy błędów i pomijamy propagacje wsteczną.

Wyniki klasyfikacji po 200 epokach
Rozpoznawana Klasa Wektor Oczekiwany (\(\bm{y}\)) Predykcja Sieci (\(\bm{\hat{y}}\))
Klasa 1 \([1, 0, 0]^T\) \([0.9175,\ 0.0696,\ 0.0153]^T\)
Klasa 2 \([0, 1, 0]^T\) \([0.0827,\ 0.9066,\ 0.0767]^T\)
Klasa 3 \([0, 0, 1]^T\) \([0.0246,\ 0.0821,\ 0.9188]^T\)

W celu sprawdzenia wykresu zależności liczby epok od błędu Tabelę 1 powtórzono dla klasyfikacji po 20 epokach treningowych.

Wyniki klasyfikacji po 20 epokach
Rozpoznawana Klasa Wektor Oczekiwany (\(\bm{t}\)) Predykcja Sieci (\(\bm{\hat{y}}\))
Klasa 1 \([1, 0, 0]^T\) \([0.3586,\ 0.3092,\ 0.2733]^T\)
Klasa 2 \([0, 1, 0]^T\) \([0.2877,\ 0.4020,\ 0.4141]^T\)
Klasa 3 \([0, 0, 1]^T\) \([0.2813,\ 0.3682,\ 0.4864]^T\)

Zestawienie ze sobą powyższych tabel, pokazuje sensownośc wykresu 2. Predykcje sieci nie są wystarczające, aby można było mówić o poprawnej klasyfikacji.

Implementacja programowa

Powyższy przykład działania perceptronu wielowarstwowego, jest przykładem elementarnym. 9-elementowa warstwa wejsciowa, nie występuje po za przykładami akademickimi. Pomimo tego jedno przejście sygnału w przód i w tył jak pokazano jest dość wymagające obliczeniowo. W jednej iteracji wykonywanych jest około 300 podstawowych operaci matematycznych. Co sprawia, że liczenie tego na kartce nawet dla tak prostych przykładów jak zaprezentowana topologia sieci 9-4-3, staje się poprostu niemożliwe. To powiedziawszy w celu rozwiązania tych obliczeń wybrano środowisko matlab. Główna pętla obliczeniowa, odpowiadająca za przepływ sygnału w przód i tył:

for epoka = 1:epoki
    blad_epoki = 0;

    for i = 1:size(X, 2)
        x_in = X(:, i);
        y = Y(:, i);


        z_1 = W_1 * x_in;
        a_1 = 1./(1+exp(-z_1));
        z_2 = W_2 * a_1;
        a_2 = 1./(1+exp(-z_2));


        delta_wyjscie = (a_2 - d) .* a_2 .* (1 - a_2);
        delta_ukryta = W_2' * delta_wyjscie .* a_1 .* (1 - a_1);

        Gradient_W_1 = delta_ukryta * x_in';
        Gradient_W_2 = delta_wyjscie * a_1';


        W_1 = W_1 - lr * Gradient_W_1;
        W_2 = W_2 - lr * Gradient_W_2;
    end
end

Weryfikacja działania na zbiorze danych MNIST

Analizowany powyżej przykład jak wsponimałem jest elementarny. W celu zweryfikowania przedstawionego rozumienia, perceptron wielowarstwowy zostanie wytrenowany do klasyfikacji obrazów cyfr ze zbioru danych MNIST.

Wprowadzenie do problemu

Zbiór danych MNIST składa się z 60000 tysięcy obrazów odręcznie pisanych cyfr o wymiarach \(28\times28\) wyglądających następująco:

Przykładowa próbka danych ze zbioru MNIST

Celem jest poprawna klasyfikacja cyfry znajdującej się na obrazie.

Topologia sieci

Wielowarstwowy perceptron operuje na wektorze danych, dlatego każdą z macierzy należy odpowiednio przygotować, przekształcając ją na wektor o długości \(28\times28=784\) warstwa wyjsciowa musi byc wektorem o dlugości \(10\), ze względu na 10 klas do klasyfikacji. Zdecydowano się na jedną warstwe ukrytą z 64 neuronami. Pętla treningowa jest identyczna jak pokazano powyżej, różnią jedynie się rozmiary macierzy które uczestniczą w mnożeniach. \[\begin{equation} \bm{X} = 784\times1 \end{equation}\] \[\begin{equation} \bm{W}^{(1)} = 64\times784 \end{equation}\] \[\begin{equation} \bm{W}^{(2)} = 10\times64 \end{equation}\]

Tak jak dla elementarnego przykładu, tu też możemy zaobserwować zależność malejącego błędu wraz z każda epoką.

Wizualizacja zależności błędu od epoki wygenerowana w środowisku MATLAB.

Szczegółowa analiza rozkładu prawdopodobieństw warstwy wyjściowej

W celu zobrazowania mechanizmu decyzyjnego, poddano analizie surowe wektory wyjściowe \(\bm{\hat{y}}\) dla pięciu losowo wybranych próbek testowych.

Granice analitycznej propagacji wstecznej i systemy Autograd

Zaimplementowany i przetestowany na bazie MNIST algorytm propagacji wstecznej dowiódł swojej poprawności oraz skuteczności. Należy jednak zauważyć, że wszystkie równania definiujące gradienty macierzy wag (\(\nabla_{\bm{W}^{(1)}} E\) oraz \(\nabla_{\bm{W}^{(2)}} E\)) zostały wyprowadzone w sposób analityczny.

To podejście może mieć swoją wadę przy bardziej skompliowanych topologiach sieci, gdzie obliczanie tego ręcznie staje się zadaniem żmudnym i czasochłonnym. W celu przetestowania nowej topologii, trzeba wyprowadzić wszystko od zera.

Rozwiązaniem problemu skalowalnosci propagacji wstecznej jest automatyczne różniczkowanie. Aktualnie sotosowane w każdej z bibliotek do deeplearningu, takich jak np. PyTorch, TensorFlow. Działanie autogradu opiera się na przechowywaniu historii obliczeń w pamięci.

Graf obliczeniowy

Każda operacja matematyczna nie jest jedynie obliczana, ale również przechowywana w pamięci w formie acyklicznego grafu skierowanego. Jest odpowiednio tworzony podczas fazy forward, tak aby składniki wyrażeń znajdowały się przed wyrażeniami je posiadającymi. Zapisywana jest również operacja, która dany węzęł wygenerwowała.

  1. Faza propagacji wstecznej: Gdy faza propagacji w przód dobiega końca, nastętpuje obliczenie wartości funkcji straty. System odwraca kolejność przechodzenia grafu, zaczynając od węzła wynikowego, idąc do węzłów rodziców. W każdym węźle aplikuje lokalną pochodną dzieki zapisowi operacji i przemnaża ją przez spływający z góry sygnał błędu.

Dzięki temu, niezależnie jak bardzo skomplikowana będzie topologia sieci, zasada łańcuchowa pisze się sama, a co za tym idzie faza backpropagation jest zautomatyzowana.

Podejście do implementacji

Językiem, który został wybrany do implementacji automatycznego gradientu jest Python. Na pythona zdecydowano się dzięki jego obiektowości i prostocie pisania. W implementacji wykorzystano jedynie numpy do oddelegowania obliczeń do silników napisanych w C.

Podstawowy blok

Aby w efektywny sposób zapisywać wykonywane operacjie, zdecydowano się operować na stworzonym type danych Value. Co pozwoliło na przechowywanie składników węzła w Pythonowym secie oraz funkcji lokalnej pochodnej.

class Tensor:
    def __init__(self, data, _children = (), _op = "", _label = ""):
        self.data = np.array(data, dtype=np.float32)
        self.grad = np.zeros_like(self.data)
        self._prev = set(_children)
        self._backward = lambda : None
        self.op = _op
        self.label = _label

Serce algorytmu

W Pythonie wszystkie operacje matematyczne, wykorzystujące znane nam podstawowe operatory \(+,-,\times,\div,e\) i potęga, można stosować jedynie do wartości skalarnych. Oznacza to, że w że w przypadku nieprymitywnego rodzaju danych np. Tensor, próba dokonania podstawowej operacji skończy się zgłoszeniem błędu przez interpreter. Dzięki obiektowości pythona i specjalnych funkcji, możemy nadpisać te podstawowe operatory, tak aby interpreter wiedział co ma zrobić podczas wystąpienia operacji na typach nieprymitywnych. Istnienie takich specjalnych funkcji pozwala na rozszerzenie operacji na potrzeby budowania grafu i obsługi propagacji wstecznej.

def __add__(self, other):
        other = other if isinstance(other, Tensor) else Tensor(other)
        
        out = Tensor(self.data + other.data, (self, other), '+')

        def _backward():
            self.grad += out.grad
            other.grad += out.grad
        
        out._backward = _backward
        return out

rowinięcie funkcji backward

Matematycznie można na to spojrzeć w ten sposób: \(x = T_1 +T_2\) \[\begin{equation} \frac{dx}{dT_1} = 1 \end{equation}\]

\[\begin{equation} \frac{dx}{dT_2} = 1 \end{equation}\] Lokalna pochodna tego wyrażenia od każdej ze zmiennych jest równa jeden. Dlatego zgodnie z zasada łańcuchową, te pochodne mnożone są przez spływjącą pochodną.

Analogicznie zostało do wykonane dla wszystkich potrzenych operatorów.

def __mul__(self, other):
        other = other if isinstance(other, Tensor) else Tensor(other)
        
        out = Tensor(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

Przykład dla mnożenia. Znowu matematycznie można na to spojrzeć w następujący sposób: \(x = T_1 * T_2\) \[\begin{equation} \frac{dx}{dT_1} = T_2 \end{equation}\]

\[\begin{equation} \frac{dx}{dT_2} = T_1 \end{equation}\]

Dlatego pochodne zgodnie z regułą łąńcuchową po przemnożeniu przez spływającą z góry pochodną wyglądają następująco. \[\begin{equation} \frac{\partial E}{\partial T_1} = T_2 * \delta \end{equation}\] \[\begin{equation} \frac{\partial E}{\partial T_2} = T_1 * \delta \end{equation}\]

def exp(self):

        out = Tensor(np.exp(self.data), (self,), 'exp')
        def _backward():
            self.grad += out.data * out.grad
        out._backward = _backward
        return out

Obiektowa struktura sieci neuronowej

Aby kod był czytelny i łatwy w rozbudowie, sieć neuronową podzielono na dwie główne klasy. Dzięki temu można budować modele o dowolnej wielkości, układając je z gotowych modułów.

Pojedyncza warstwa (Klasa Linear)

Klasa ta reprezentuje jedną warstwę neuronów, warstwę w pełni połączoną.

class Linear:
    def __init__(self, nin, nout):
        

        self.w = Tensor(np.random.randn(nin, nout))
        self.b = Tensor(np.zeros(nout))

    def __call__(self, X):

        out = X @ self.w + self.b
        return out

    def parameters(self):
        return [self.w, self.b]
        

Połączona sieć (Klasa MLP)

Klasa MLP łączy pojedyncze warstwy w kompletną, gotową do działania sieć.

class MLP:
    def __init__(self, nin, nouts, dropout_p = 0.2):
        sz = [nin] + nouts
        self.layers = [Linear(sz[i], sz[i+1]) for i in range(len(nouts))]


    def __call__(self, X):

        for i, layer in enumerate(self.layers):

            X = layer(X)

            if i < len(self.layers) - 1:
                X = X.relu()
        return X
    
    def parameters(self):
        
        params = []
        for layer in self.layers:
            params.extend(layer.parameters())

        return params

    

Implementacja grafu

Opisywany wcześniej acykliczny graf skierowany w pythonowej implementacji powstaje po przepływie sygnału w przód. Struktura przyjmuje formę pythonowej listy, która tworzona jest w oparciu o rekurencyjną implementację algorytmu DFS. Wykorzystując przypisanych przodków do obiektów typu danych Tensor.


 def backward(self):
        topo = []
        visited = set()
        def build_topo(value):
            if value not in visited:
                visited.add(value)
                for child in value._prev:
                    build_topo(child)
                topo.append(value)
    
        build_topo(self)
        

Następnie elementem wyzwalającym propagację wsteczną jest odwrócenie powstałej listy i wywołanie na każdej z jej elementów, odpowiedniej funkcji obliczającej pochodną lokalną.

self.grad = 1.0
        for x in reversed(topo):
            x._backward()

Zaprezentowane implementacja, przetestowana zostanie na dwówch zbiorach danych.

Test na MNNIST

Test silnika autograd na opisywanym wcześniej zbiorze danych MNIST. Topologia trenowanego perceptronu to:\(784-128-64-10\)Algorytm optymalizacyjny zastosowany w tej pętli jest taki sam jak dla kodu Matlabowego. Pętla ucząca wygląda następująco


 batch_size = 64
n_samples = x_train.shape[0]
learning_rate = 0.01


optimizer = SGD(m.parameters())

for epoch in range(20):

    sum_loss = 0.0
    batch_count = 0.0

    lr = learning_rate * (0.9 ** epoch)
    
    r_indexes = np.random.permutation(n_samples)

    for i in range(0, n_samples, batch_size):

        batch_indexes = r_indexes[i: i +64]

        X_batch = Tensor(X_train.data[batch_indexes])
        Y_batch = Tensor(Y_train.data[batch_indexes])

        optimizer.zero_grad()

        ypreds = m(X_batch)

        loss, prawdopodobienstwa = cross_entropy_loss(ypreds, Y_batch)

        loss.backward()

        optimizer.step()
        

Gdzie SGD jest zaimplementowaną klasą pomocniczą przygotowywującą kod na testy róznych optymalizatorów

from abc import ABC, abstractmethod

class Optimizer:
    def __init__(self, parameters, learning_rate=0.001):
        self.parameters = parameters
        self.learning_rate = learning_rate
    def zero_grad(self):
        for p in self.parameters:
            p.grad = np.zeros_like(p.data)
    @abstractmethod
    def step(self):
        pass

        

class SGD(Optimizer):
    def __init__(self, parameters ,learning_rate=0.001):
        super().__init__(parameters, learning_rate)
        self.momentum = 0.9

    def step(self):

        for p in self.parameters:
            p.data += -self.learning_rate*p.grad

        

Czas trenowania, czyli czas w którym przez sieć przechodzi sygnał w przód i wstecz wraz ze zmianą parametrów dla każdej z par treningowych trwa około 15 sekund na chipie M4 macbooka pro.

Zestawienie spadku wartości funkcji straty w wybranych epokach procesu uczenia
Epoka (Epoch) Wartość funkcji straty (Loss)
5 0.07254
10 0.02795
15 0.01500
20 0.01073

Po fazie uczenia przetestowano recall perceptronu dla dwóch zbiórów. Zbioru testowego i treningowego, czyli tego na, którym odbywała się faza uczenia. Zbiór testowy składa się z 10 tysięcy elementów.

Porównanie skuteczności klasyfikacji (Accuracy) dla zbioru treningowego i testowego bazy MNIST
Zbiór danych Liczba próbek Poprawne predykcje Dokładność
Treningowy 60 000 59 819 99.70%
Testowy 10 000 9 780 97.80%

W celu dalszej weryfikacji predykcji sieci, zwizualizowano losowe próbki.

Wizualizacja predykcji sieci.

Test na fashion MNIST

Drugi ropatrywany zbiór danych jest zbiorem bliźniaczo podobnym do oryginalnego MNISTA. Format danych jak i ilość jest identyczna. Też mamy tu doczynienia z 10 klasami, rozmiarem \(28\times28\) i ilościa 60000 elementów w zbiorze testowym. Różnica polega na tym, że zamiast cyfr, w zbiorze zawierają się obrazy ubrań, które z natury są bardziej skomplikowane i trudniejsze do klasyfikacji dla wielowarstwowego perceptronu. Wynika to z większej możliwej różnorodności przykładów w porównaniu do cyfr. Co pokazują przeprowadzone testy.

Pętla treningowa wygląda identycznie jak w przypadku klasycznego MNIST

Porównanie skuteczności klasyfikacji dla zbioru treningowego i testowego bazy Fashion MNIST
Zbiór danych Liczba próbek Poprawne predykcje Dokładność
Treningowy 60 000 55 860 93.10%
Testowy 10 000 8 881 88.81%

Jak widać przypuszczenia gorszej skuteczności perceptronu wielowarstwowego dla bardziej skomplikowanych zostały potwierdzone. Dla pewności przetestowana została również większa liczba epok, zwiększkono ją do 50 co jak widać w tabeli poniżej nie przyniosło oczekiwanych rezultatów.

Porównanie skuteczności klasyfikacji (Accuracy) dla nowego wariantu uczenia sieci
Zbiór danych Liczba próbek Poprawne predykcje Dokładność
Treningowy 60 000 58 589 97.65%
Testowy 10 000 8 919 89.19%

Można zaobserwować znaczny wzrost w dokładności predykcji dokonywanych na zbiorze treningowym. Wynika to z uczenia się perceptronu "na pamięć", co nazywane jest przeuczeniem sieci i jest zjawiskiem nie pożądanym. Okolice osiągnietego wyniku \(90\%\) są maksimum dla architektury wielowarstwowego percetpronu.

Wizualizacja predykcji sieci.

9

Rosenblatt, F. (1958). The perceptron: A probabilistic model for information storage and organization in the brain. Psychological Review, 65(6), 386–408.

McCulloch, W. S., & Pitts, W. (1943). A logical calculus of the ideas immanent in nervous activity. The bulletin of mathematical biophysics, 5(4), 115–133.

Zurada, J. M. (1992). Introduction to Artificial Neural Systems. West Publishing Company, St. Paul.

Rumelhart, D. E., Hinton, G. E., & Williams, R. J. (1986). Learning representations by back-propagating errors. Nature, 323(6088), 533–536.