Powrót do strony głównej

Spadek gradientowy i Backpropagation w sieciach neuronowych: CUDA/C++

Głębokie zanurzenie w spadek gradientowy i propagację wsteczną błędu. Implementacja algorytmów uczenia sieci neuronowych od zera na CUDA/C++ dla MNIST. Szczegółowy rozbiór matematyki i kodu.

Spadek gradientowy i Backpropagation: Uczenie sieci neuronowych na CUDA/C++ od zera
Advertisement 728x90

Spadek gradientowy i wsteczna propagacja błędu: Szkolenie sieci neuronowych od podstaw w CUDA/C++

Ten artykuł to czwarta część dogłębnego zanurzenia w świat sieci neuronowych, gdzie krok po kroku budujemy architekturę Transformer, zaczynając od najbardziej podstawowych komponentów w CUDA. W tym wydaniu przechodzimy od implementacji przejścia w przód do kluczowego etapu – szkolenia modelu. Omówimy fundamentalne mechanizmy spadku gradientowego (Gradient Descent) i wstecznej propagacji błędu (Backpropagation), zaimplementujemy je na niskim poziomie, używając C++ i CUDA, aby nauczyć naszą sieć neuronową rozpoznawania odręcznych cyfr z zestawu danych MNIST. Celem nie jest tylko użycie gotowych bibliotek, ale dogłębne zrozumienie, jak działają te złożone procesy obliczeniowe „pod maską”, co jest kluczowe dla głębokiego pojmowania współczesnych modeli uczenia maszynowego i sztucznej inteligencji.

Podstawy szkolenia sieci neuronowych: Spadek gradientowy

Po pomyślnej implementacji przejścia w przód w poprzedniej części, nasz model jest zdolny do dokonywania przewidywań. Jednak na początkowym etapie, bez szkolenia, te przewidywania będą przypadkowe. Aby model zaczął generować poprawne wyniki, konieczne jest korygowanie jego wewnętrznych parametrów (wag i biasów) w taki sposób, aby zminimalizować błąd między przewidywaniami a prawdziwymi etykietami. Właśnie do tego celu wykorzystuje się algorytmy takie jak spadek gradientowy i wsteczna propagacja błędu.

Wyobraź sobie, że znajdujesz się na szczycie góry w gęstej mgle, a Twoim zadaniem jest zejść do najniższego punktu doliny. Nie widzisz całego terenu, ale możesz wyczuć nachylenie pod stopami. Gradient to wektor wskazujący kierunek największego wzniesienia. Zatem, aby zejść w dół, musisz poruszać się w kierunku przeciwnym do gradientu. W kontekście sieci neuronowych, „góra” to powierzchnia funkcji błędu (lub funkcji straty), a „dolina” to jej globalne minimum. Celem spadku gradientowego jest znalezienie tego minimum poprzez iteracyjne korygowanie wag sieci w kierunku przeciwnym do gradientu funkcji błędu względem tych wag.

Google AdInline article slot

Wsteczna propagacja błędu (Backpropagation) to algorytm, który pozwala efektywnie obliczać te gradienty. Działa on poprzez przekazywanie błędu z warstwy wyjściowej sieci z powrotem do warstwy wejściowej, rozdzielając odpowiedzialność za błąd pomiędzy wszystkie wagi. Matematycznie jest to realizowane za pomocą reguły łańcuchowej (Chain Rule) różniczkowania, która pozwala obliczyć, jak zmiana każdej wagi wpływa na całkowity błąd modelu.

Funkcja straty: Entropia krzyżowa

Do pomiaru „jakości” przewidywań modelu używa się funkcji straty. W zadaniach klasyfikacji, takich jak rozpoznawanie odręcznych cyfr MNIST, standardowym wyborem jest entropia krzyżowa (Cross-Entropy Loss). Pozwala ona ilościowo ocenić rozbieżność między rozkładem prawdopodobieństw przewidzianym przez model a rozkładem prawdziwym (gdzie tylko poprawna klasa ma prawdopodobieństwo 1, a pozostałe 0). Im większa ta rozbieżność, tym wyższa wartość funkcji straty.

Dla pojedynczego przykładu entropia krzyżowa jest obliczana według wzoru:

Google AdInline article slot

![L = -\sum_{i=1}^{K} y_i \log(\hat{y}_i)](./images/image-1.svg)

Gdzie:

  • ![K](./images/image-2.svg) — liczba klas (dla MNIST to 10).
  • ![y_i](./images/image-3.svg) — prawdziwa wartość (1 dla poprawnej klasy, 0 dla pozostałych).
  • ![$\hat{y}_i$](./images/image-4.svg) — przewidywanie modelu (prawdopodobieństwo uzyskane po warstwie Softmax).

Ponieważ zazwyczaj szkolenie odbywa się na batchach (pakietach) danych, a nie na pojedynczych przykładach, całkowita funkcja straty dla batcha jest uśredniana względem jego rozmiaru (![$N$](./images/image-5.svg) — rozmiar batcha):

Google AdInline article slot

![L = -\frac{1}{N} \sum_{j=1}^{N} \sum_{i=1}^{K} y_{j,i} \log(\hat{y}_{j,i})](./images/image-6.svg)

Poniżej przedstawiono kod w C++ do obliczania entropii krzyżowej na CPU, co upraszcza debugowanie i zrozumienie:

float cross_entropy_loss_cpu(
  const Tensor& predictions, const std::vector<int>& labels
) {
  int batch_size = predictions.shape()[0];
  int num_classes = predictions.shape()[1];
  float loss = 0.0f;
  for (int i = 0; i < batch_size; i++) {
    for (int j = 0; j < num_classes; j++) {
      float prob = predictions.get({i, j});
      float target = (labels[i] == j) ? 1.0f : 0.0f;
      loss += -std::log(prob) * target;
    }
  }
  return loss / batch_size;
}

Wsteczna propagacja i reguła łańcuchowa

Aby zminimalizować funkcję straty ![$L$](./images/image-9.svg), musimy wiedzieć, jak jej wartość zmienia się przy niewielkiej zmianie każdej wagi w sieci. Jest to właśnie gradient funkcji straty względem wag. Na przykład, dla wagi ![$W_1$](./images/image-10.svg) pierwszej warstwy musimy znaleźć ![$\frac{\partial L}{\partial W_1}$](./images/image-8.svg).

Architektura naszej sieci, opracowana w poprzednich artykułach, wygląda następująco:

  • Pierwsza warstwa liniowa: ![$z_1 = W_1 \cdot x + b_1$](./images/image-11.svg)
  • Aktywacja (ReLU): ![$a_1 = \sigma(z_1)$](./images/image-12.svg)
  • Druga warstwa liniowa: ![$z_2 = W_2 \cdot a_1 + b_2$](./images/image-13.svg)
  • Wyjście (Softmax + Loss): ![$L = Loss(Softmax(z_2))$](./images/image-14.svg)

Do obliczenia gradientu ![$\frac{\partial L}{\partial W_1}$](./images/image-16.svg) stosujemy regułę łańcuchową (Chain Rule):

![$\frac{\partial L}{\partial W_1} = \frac{\partial L}{\partial z_2} \cdot \frac{\partial z_2}{\partial a_1} \cdot \frac{\partial a_1}{\partial z_1} \cdot \frac{\partial z_1}{\partial W_1}$](./images/image-17.svg)

Każdy człon w tym łańcuchu reprezentuje lokalny gradient, który pokazuje, jak wyjście danej warstwy lub funkcji wpływa na jej wejście. Wsteczna propagacja rozpoczyna się od obliczenia gradientu funkcji straty względem wyjść ostatniej warstwy, a następnie kolejno przekazuje ten gradient z powrotem przez wszystkie warstwy sieci.

Szczegółowe wyprowadzenie gradientów dla Softmax i entropii krzyżowej

Zacznijmy od samego końca łańcucha – pochodnej funkcji straty ![$L$](./images/image-25.svg) względem wyjść drugiej warstwy liniowej ![$z_2$](./images/image-20.svg) po zastosowaniu Softmax. Jest to jeden z najważniejszych i najczęściej używanych gradientów. Przypomnijmy, jak wygląda Softmax dla każdego elementu ![$i$](./images/image-19.svg) w wektorze ![$z_2$](./images/image-20.svg):

![$a_{2, i} = \frac{e^{z_i}}{\sum_{j=1}^{K} e^{z_j}}$](./images/image-21.svg)

Gdzie ![$z_2$](./images/image-22.svg) — wyjście drugiej warstwy liniowej (logity), a ![$a_2$](./images/image-23.svg) — wynik Softmax (prawdopodobieństwa). Gradient ![$\frac{\partial L}{\partial z_{2,i}}$](./images/image-18.svg) można rozłożyć zgodnie z regułą łańcuchową:

![$\frac{\partial L}{\partial z_{2,i}} = \sum_{k=1}^{K} \frac{\partial L}{\partial a_{2,k}} \cdot \frac{\partial a_{2, k}}{\partial z_{2,i}}$](./images/image-24.svg)

Najpierw znajdźmy pochodną funkcji straty ![$L$](./images/image-27.svg) względem ![$a_i$](./images/image-26.svg):

![$\frac{\partial L}{\partial a_i} = \frac{\partial}{\partial a_i} \left( -y_i \ln(a_i) \right) = -y_i \cdot \frac{1}{a_i} = -\frac{y_i}{a_i}$](./images/image-29.svg)

Teraz przejdźmy do pochodnej Softmax względem ![$z$](./images/image-30.svg). Tutaj będzie potrzebna reguła pochodnej ilorazu. Wzór Softmax: ![$a_i = \frac{e^{z_i}}{\sum_{k=1}^K e^{z_k}}$](./images/image-32.svg)

Jeśli ![$i=j$](./images/image-43.svg), pochodna wynosi:

![$\frac{\partial a_i}{\partial z_i} = a_i (1 - a_i)$](./images/image-36.svg)

Jeśli ![$i \neq j$](./images/image-37.svg), pochodna wynosi:

![$\frac{\partial a_i}{\partial z_j} = -a_i a_j$](./images/image-41.svg)

Używając symbolu Kroneckera ![$\delta_{ij}$](./images/image-42.svg), te dwa przypadki można połączyć:

![$\frac{\partial a_i}{\partial z_j} = a_i (\delta_{ij} - a_j)$](./images/image-44.svg)

Łącząc pochodne entropii krzyżowej i Softmax, otrzymujemy:

![$\frac{\partial L}{\partial z_i} = \sum_{k=1}^{K} \left( -\frac{y_k}{a_k} \right) \cdot \left( a_k(\delta_{ki} - a_i) \right) = \sum_{k=1}^{K} -y_k (\delta_{ki} - a_i)$](./images/image-49.svg)

Dalsze uproszczenie, biorąc pod uwagę, że suma prawdziwych prawdopodobieństw ![$\sum_{k=1}^{K} y_k$](./images/image-51.svg) zawsze wynosi 1, prowadzi do zaskakująco prostego i eleganckiego wzoru:

![$\frac{\partial L}{\partial z_i} = a_i - y_i$](./images/image-52.svg)

Oznacza to, że gradient funkcji straty względem logitów (wyjść przed Softmax) to po prostu różnica między przewidywanymi prawdopodobieństwami a prawdziwymi etykietami. Ten wynik jest kamieniem węgielnym dla implementacji wstecznej propagacji błędu w modelach klasyfikacyjnych.

Implementacja gradientu w C++

Stosując uzyskany wzór, możemy zaimplementować obliczanie gradientu dla ostatniej warstwy naszej sieci neuronowej. Ten gradient zostanie następnie wykorzystany do aktualizacji wag, przesuwając model w kierunku mniejszego błędu. Tak to wygląda w kodzie C++:

auto grad = Tensor::create_zeros({ batch_size, num_classes });

for (int i = 0; i < batch_size; ++i) {
  for (int j = 0; j < num_classes; ++j) {
    float prob = fc2.output->get({i, j});
    float target = (targets[i] == j) ? 1.0f : 0.0f;
    float gradient = (prob - target) / batch_size; // Dzielenie przez batch_size w celu uśrednienia
    grad->set({i, j}, gradient);
  }
}

Ten kod oblicza gradient błędu dla każdego elementu w batchu, a następnie uśrednia go względem rozmiaru batcha. Uzyskany tensor grad zawiera informację o tym, o ile należy zmienić logity ![$z_2$](./images/image-54.svg), aby zmniejszyć błąd. Następnie ten gradient będzie propagowany wstecz przez poprzednie warstwy sieci, umożliwiając korygowanie wag ![$W_2$](./images/image-13.svg), ![$b_2$](./images/image-13.svg), ![$W_1$](./images/image-11.svg) i ![$b_1$](./images/image-11.svg) z wykorzystaniem odpowiednich lokalnych pochodnych.

Co ważne:

  • Spadek gradientowy — to iteracyjna metoda optymalizacji, która koryguje parametry modelu w kierunku przeciwnym do gradientu funkcji straty, aby znaleźć jej minimum.
  • Wsteczna propagacja błędu (Backpropagation) — algorytm wykorzystujący regułę łańcuchową do efektywnego obliczania gradientów funkcji straty względem wszystkich wag sieci neuronowej.
  • Entropia krzyżowa — standardowa funkcja straty dla zadań klasyfikacji, mierząca rozbieżność między przewidywanymi a prawdziwymi rozkładami prawdopodobieństw.
  • Pochodna Softmax + Entropia Krzyżowa względem logitów upraszcza się do różnicy między przewidywanymi prawdopodobieństwami a prawdziwymi etykietami, co znacznie upraszcza implementację.
  • Implementacja od podstaw w C++/CUDA pozwala na głębokie zrozumienie wewnętrznych mechanizmów działania modeli, takich jak Transformer, bez użycia wysokopoziomowych abstrakcji.

— Editorial Team

Advertisement 728x90

Czytaj dalej