Zpět na domů

Gradientový sestup a Backpropagation v neuronových sítích: CUDA/C++

Hloubkové ponoření do gradientového sestupu a zpětné propagace. Implementace algoritmů tréninku neuronových sítí od nuly na CUDA/C++ pro MNIST. Podrobný rozbor matematiky a kódu.

Gradientový sestup a Backpropagation: Trénink neuronových sítí na CUDA/C++ od nuly
Advertisement 728x90

Gradientní sestup a zpětná propagace chyby: Trénink neuronových sítí od základů v CUDA/C++

Tento článek je čtvrtou částí hlubokého ponoru do světa neuronových sítí, kde krok za krokem budujeme architekturu Transformer, počínaje těmi nejzákladnějšími komponentami v CUDA. V tomto díle přecházíme od implementace dopředného průchodu ke klíčové fázi – tréninku modelu. Rozebereme si fundamentální mechanismy gradientního sestupu (Gradient Descent) a zpětné propagace chyby (Backpropagation), implementujeme je na nízké úrovni s použitím C++ a CUDA, abychom naučili naši neuronovou síť rozpoznávat ručně psané číslice z datasetu MNIST. Cílem není jen používat hotové knihovny, ale do hloubky pochopit, jak tyto složité výpočetní procesy fungují „pod kapotou“, což je kriticky důležité pro hluboké porozumění moderním modelům strojového učení a umělé inteligence.

Základy tréninku neuronových sítí: Gradientní sestup

Poté, co jsme v předchozí části úspěšně implementovali dopředný průchod, je náš model schopen provádět predikce. Na počátku, bez tréninku, však budou tyto predikce náhodné. Aby model začal poskytovat správné výsledky, je nutné upravit jeho vnitřní parametry (váhy a bias) tak, aby se minimalizovala chyba mezi predikcemi a skutečnými štítky. Právě k tomuto účelu se používají algoritmy jako gradientní sestup a zpětná propagace chyby.

Představte si, že se nacházíte na vrcholu hory v husté mlze a vaším úkolem je sestoupit do nejnižšího bodu údolí. Nevidíte celou krajinu, ale cítíte sklon pod nohama. Gradient je vektor ukazující směr největšího stoupání. Abyste sestoupili, musíte se pohybovat v opačném směru než gradient. V kontextu neuronových sítí je „hora“ povrchem chybové funkce (nebo ztrátové funkce) a „údolí“ je jejím globálním minimem. Cílem gradientního sestupu je najít toto minimum iterativním upravováním vah sítě ve směru opačném ke gradientu chybové funkce vzhledem k těmto vahám.

Google AdInline article slot

Zpětná propagace chyby (Backpropagation) je algoritmus, který umožňuje efektivně vypočítat tyto gradienty. Funguje tak, že chybu přenáší z výstupní vrstvy sítě zpět k vstupní, čímž rozděluje odpovědnost za chybu mezi všechny váhy. Matematicky je to realizováno pomocí řetězového pravidla (Chain Rule) derivace, které umožňuje vypočítat, jak změna každé váhy ovlivňuje celkovou chybu modelu.

Ztrátová funkce: Křížová entropie

Pro měření „špatnosti“ predikcí modelu se používá ztrátová funkce. V klasifikačních úlohách, jako je rozpoznávání ručně psaných číslic MNIST, je standardní volbou křížová entropie (Cross-Entropy Loss). Umožňuje kvantitativně vyhodnotit rozdíl mezi distribucí pravděpodobností předpovězenou modelem a skutečnou distribucí (kde pouze správná třída má pravděpodobnost 1 a ostatní 0). Čím větší je tento rozdíl, tím vyšší je hodnota ztrátové funkce.

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

Google AdInline article slot

Kde:

  • ![K](./images/image-2.svg) — počet tříd (pro MNIST je to 10).
  • ![y_i](./images/image-3.svg) — skutečná hodnota (1 pro správnou třídu, 0 pro ostatní).
  • ![\hat{y}_i](./images/image-4.svg) — predikce modelu (pravděpodobnost získaná po vrstvě Softmax).

Jelikož se trénink obvykle provádí na dávkách (batchích) dat, nikoli na jednotlivých příkladech, celková ztrátová funkce pro dávku se zprůměruje podle její velikosti (![N](./images/image-5.svg) — velikost dávky):

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

Google AdInline article slot

Níže je uveden kód v C++ pro výpočet křížové entropie na CPU, což zjednodušuje ladění a porozumění:

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;
}

Zpětná propagace a řetězové pravidlo

Abychom minimalizovali ztrátovou funkci ![L](./images/image-9.svg), musíme vědět, jak se její hodnota mění při malé změně každé váhy v síti. To je právě gradient ztrátové funkce vzhledem k vahám. Například pro váhu ![W_1](./images/image-10.svg) první vrstvy potřebujeme najít ![\frac{\partial L}{\partial W_1}](./images/image-8.svg).

Architektura naší sítě, vyvinutá v předchozích článcích, vypadá následovně:

  • První lineární vrstva: ![z_1 = W_1 \cdot x + b_1](./images/image-11.svg)
  • Aktivace (ReLU): ![a_1 = \sigma(z_1)](./images/image-12.svg)
  • Druhá lineární vrstva: ![z_2 = W_2 \cdot a_1 + b_2](./images/image-13.svg)
  • Výstup (Softmax + Loss): ![L = Loss(Softmax(z_2))](./images/image-14.svg)

Pro výpočet gradientu ![\frac{\partial L}{\partial W_1}](./images/image-16.svg) aplikujeme řetězové pravidlo (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ždý člen v tomto řetězci představuje lokální gradient, který ukazuje, jak výstup dané vrstvy nebo funkce ovlivňuje její vstup. Zpětná propagace začíná výpočtem gradientu ztrátové funkce vzhledem k výstupům poslední vrstvy a poté postupně předává tento gradient zpět přes všechny vrstvy sítě.

Detailní odvození gradientů pro Softmax a křížovou entropii

Začněme od samého konce řetězce – derivací ztrátové funkce ![L](./images/image-25.svg) vzhledem k výstupům druhé lineární vrstvy ![z_2](./images/image-20.svg) po aplikaci Softmax. Toto je jeden z nejdůležitějších a nejčastěji používaných gradientů. Připomeňme si, jak vypadá Softmax pro každý prvek ![i](./images/image-19.svg) ve vektoru ![z_2](./images/image-20.svg):

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

Kde ![z_2](./images/image-22.svg) je výstup druhé lineární vrstvy (logity) a ![a_2](./images/image-23.svg) je výsledek Softmaxu (pravděpodobnosti). Gradient ![\frac{\partial L}{\partial z_{2,i}}](./images/image-18.svg) lze rozložit podle řetězového pravidla:

![\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)

Nejprve najdeme derivaci ztrátové funkce ![L](./images/image-27.svg) vzhledem k ![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)

Nyní přejdeme k derivaci Softmaxu vzhledem k ![z](./images/image-30.svg). Zde bude potřeba pravidlo pro derivaci podílu. Vzorec Softmaxu: ![a_i = \frac{e^{z_i}}{\sum_{k=1}^K e^{z_k}}](./images/image-32.svg)

Pokud ![i=j](./images/image-43.svg), derivace je rovna:

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

Pokud ![i \neq j](./images/image-37.svg), derivace je rovna:

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

Pomocí Kroneckerova symbolu ![\delta_{ij}](./images/image-42.svg) lze tyto dva případy sjednotit:

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

Kombinací derivací křížové entropie a Softmaxu získáme:

![\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)

Další zjednodušení, s přihlédnutím k tomu, že součet skutečných pravděpodobností ![\sum_{k=1}^{K} y_k](./images/image-51.svg) je vždy roven 1, vede k překvapivě jednoduchému a elegantnímu vzorci:

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

To znamená, že gradient ztrátové funkce vzhledem k logitům (výstupům před Softmaxem) je jednoduše rozdíl mezi předpovězenými pravděpodobnostmi a skutečnými štítky. Tento výsledek je základním kamenem pro implementaci zpětné propagace chyby v klasifikačních modelech.

Implementace gradientu v C++

Použitím odvozeného vzorce můžeme implementovat výpočet gradientu pro poslední vrstvu naší neuronové sítě. Tento gradient bude následně použit k aktualizaci vah, čímž se model posune směrem k menší chybě. Zde je, jak to vypadá v kódu 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; // Dělení na batch_size pro zprůměrování
    grad->set({i, j}, gradient);
  }
}

Tento kód vypočítá gradient chyby pro každý prvek v dávce a poté jej zprůměruje podle velikosti dávky. Výsledný tenzor grad obsahuje informace o tom, o kolik je třeba změnit logity ![z_2](./images/image-54.svg), aby se chyba snížila. Dále bude tento gradient propagován přes předchozí vrstvy sítě, což umožní korigovat váhy ![W_2](./images/image-13.svg), ![b_2](./images/image-13.svg), ![W_1](./images/image-11.svg) a ![b_1](./images/image-11.svg) s použitím odpovídajících lokálních derivací.

Co je důležité:

  • Gradientní sestup — je iterační optimalizační metoda, která upravuje parametry modelu ve směru opačném ke gradientu ztrátové funkce, aby nalezla její minimum.
  • Zpětná propagace chyby (Backpropagation) — algoritmus, který využívá řetězové pravidlo pro efektivní výpočet gradientů ztrátové funkce vzhledem ke všem vahám neuronové sítě.
  • Křížová entropie — standardní ztrátová funkce pro klasifikační úlohy, která měří rozdíl mezi předpovězenými a skutečnými distribucemi pravděpodobností.
  • Derivace Softmax + Křížová entropie vzhledem k logitům se zjednodušuje na rozdíl mezi předpovězenými pravděpodobnostmi a skutečnými štítky, což výrazně zjednodušuje implementaci.
  • Implementace od základů v C++/CUDA umožňuje hluboké porozumění vnitřním mechanismům fungování modelů, jako je Transformer, bez použití vysokoúrovňových abstrakcí.

— Editorial Team

Advertisement 728x90

Číst dál