1. Metodyka propagacji wstecznej
Propagacja wsteczna jest podstawowym algorytmem uczenia sieci neuronowych. Po raz pierwszy sformalizowana w latach 80. XX wieku i spopularyzowana przez Rumelharta, Hintona i Williamsa 1, stanowi eleganckie zastosowanie reguły łańcuchowej, które umożliwia wydajne obliczanie gradientów w sieciach wielowarstwowych, czyniąc głębokie uczenie wykonalnym w praktyce.
W swojej istocie propagacja wsteczna rozwiązuje fundamentalny problem: jak wydajnie obliczyć gradient funkcji straty względem każdej wagi w sieci, która może mieć miliony parametrów? Odpowiedź polega na systematycznym propagowaniu sygnałów błędu wstecz przez sieć oraz ponownym wykorzystywaniu wyników pośrednich, aby uniknąć zbędnych obliczeń.
1.1 Problem uczenia
Rozważmy sieć neuronową jako funkcję parametryzowaną \(f(x; \theta)\), która odwzorowuje wejścia \(x\) na wyjścia \(\hat{y}\). Parametry \(\theta\) obejmują wszystkie wagi i wyrazy wolne we wszystkich warstwach. Naszym celem jest znalezienie parametrów minimalizujących funkcję straty:
gdzie:
- \(N\) to liczba przykładów treningowych
- \(L(y, \hat{y})\) to strata dla pojedynczej predykcji (np. błąd średniokwadratowy lub entropia krzyżowa)
- \(y_i\) to prawdziwa etykieta dla wejścia \(x_i\)
Aby zminimalizować tę stratę metodą spadku gradientowego, musimy obliczyć:
Propagacja wsteczna zapewnia wydajny algorytm obliczania tych gradientów.
1.2 Architektura sieci
Zbudujmy intuicję na przykładzie wielowarstwowej sieci jednokierunkowej. Dla warstwy \(l\) (gdzie \(l = 1, 2, \ldots, L\)):
Wartość przed aktywacją (suma ważona):
Aktywacja (nieliniowość):
gdzie:
- \(W^{(l)} \in \mathbb{R}^{n_l \times n_{l-1}}\) to macierz wag warstwy \(l\)
- \(b^{(l)} \in \mathbb{R}^{n_l}\) to wektor wyrazów wolnych
- \(a^{(l-1)}\) to aktywacja poprzedniej warstwy (przy czym \(a^{(0)} = x\) jest wejściem)
- \(g^{(l)}(\cdot)\) to funkcja aktywacji (np. ReLU, sigmoidalna, tanh)
- \(n_l\) to liczba neuronów w warstwie \(l\)
Końcowe wyjście to \(\hat{y} = a^{(L)}\), a strata dla pojedynczego przykładu wynosi:
1.3 Przejście w przód
Zanim będziemy mogli propagować błędy wstecz, musimy najpierw wykonać przejście w przód, czyli obliczyć predykcje sieci. Jest to proste:
- Inicjalizacja: Ustaw \(a^{(0)} = x\) (wejście)
- Dla każdej warstwy \(l = 1, 2, \ldots, L\):
- Oblicz \(z^{(l)} = W^{(l)} a^{(l-1)} + b^{(l)}\)
- Oblicz \(a^{(l)} = g^{(l)}(z^{(l)})\)
- Wyjście: \(\hat{y} = a^{(L)}\)
- Oblicz stratę: \(L = L(y, \hat{y})\)
Podczas tego przejścia zapisujemy wszystkie wartości pośrednie \(z^{(l)}\) i \(a^{(l)}\), ponieważ będą niezbędne podczas przejścia wstecz.
1.4 Przejście wstecz: wyprowadzenie propagacji wstecznej
Teraz przechodzimy do eleganckiej części. Chcemy obliczyć \(\frac{\partial L}{\partial W^{(l)}}\) oraz \(\frac{\partial L}{\partial b^{(l)}}\) dla każdej warstwy \(l\). Kluczowy pomysł polega na pracy od warstwy wyjściowej wstecz, z obliczaniem i zapisywaniem gradientów pośrednich.
1.4.1 Błąd warstwy wyjściowej
Dla ostatniej warstwy \(L\) zaczynamy od obliczenia, jak strata zmienia się względem wartości przed aktywacją:
Z reguły łańcuchowej:
gdzie \(\odot\) oznacza mnożenie element po elemencie (iloczyn Hadamarda), a \(g'^{(L)}\) jest pochodną funkcji aktywacji.
Dla często spotykanych połączeń funkcji straty i aktywacji otrzymujemy proste postacie:
Błąd średniokwadratowy z wyjściem liniowym:
Entropia krzyżowa z wyjściem softmax:
(gdzie \(y\) jest wektorem zakodowanym metodą one-hot)
1.4.2 Błędy warstw ukrytych
Dla warstw ukrytych (\(l < L\)) propagujemy błąd wstecz za pomocą reguły łańcuchowej. Błąd w warstwie \(l\) zależy od błędu w warstwie \(l+1\):
Ponieważ \(z^{(l)}\) wpływa na stratę poprzez \(a^{(l)}\), które z kolei wpływa na \(z^{(l+1)}\):
Z równania \(\ref{eq:pre_activation}\) wiemy, że \(z^{(l+1)} = W^{(l+1)} a^{(l)} + b^{(l+1)}\), zatem:
Z równania \(\ref{eq:activation}\) otrzymujemy natomiast:
Łącząc te zależności, otrzymujemy równanie propagacji wstecznej dla warstw ukrytych:
To sedno propagacji wstecznej: błędy z warstwy \(l+1\) są propagowane wstecz przez wagi i modulowane przez pochodną funkcji aktywacji.
1.4.3 Gradienty względem parametrów
Gdy znamy \(\delta^{(l)}\) dla każdej warstwy, obliczenie gradientów jest proste:
Gradient względem wag:
Gradient względem wyrazów wolnych:
Wzory te wynikają bezpośrednio z zastosowania reguły łańcuchowej do równań \(\ref{eq:pre_activation}\) i \(\ref{eq:activation}\).
1.5 Pełny algorytm propagacji wstecznej
Łącząc wszystkie elementy, otrzymujemy pełny algorytm dla pojedynczego przykładu treningowego:
1.5.1 Algorytm: propagacja wsteczna
Wejście: Przykład treningowy \((x, y)\), parametry sieci \(\{W^{(l)}, b^{(l)}\}_{l=1}^L\)
Wyjście: Gradienty \(\{\frac{\partial L}{\partial W^{(l)}}, \frac{\partial L}{\partial b^{(l)}}\}_{l=1}^L\)
Przejście w przód:
- Ustaw \(a^{(0)} = x\)
- Dla \(l = 1\) do \(L\):
- \(z^{(l)} = W^{(l)} a^{(l-1)} + b^{(l)}\)
- \(a^{(l)} = g^{(l)}(z^{(l)})\)
- Oblicz stratę: \(L = L(y, a^{(L)})\)
Przejście wstecz:
- Oblicz błąd wyjściowy: \(\delta^{(L)} = \frac{\partial L}{\partial a^{(L)}} \odot g'^{(L)}(z^{(L)})\)
- Dla \(l = L-1\) w dół do \(1\):
- \(\delta^{(l)} = (W^{(l+1)})^T \delta^{(l+1)} \odot g'^{(l)}(z^{(l)})\)
- Dla \(l = 1\) do \(L\):
- \(\frac{\partial L}{\partial W^{(l)}} = \delta^{(l)} (a^{(l-1)})^T\)
- \(\frac{\partial L}{\partial b^{(l)}} = \delta^{(l)}\)
1.6 Wydajność obliczeniowa
Propagacja wsteczna jest wyjątkowo wydajna. Bez niej naiwne obliczanie gradientów wymagałoby \(O(W^2)\) operacji, gdzie \(W\) jest całkowitą liczbą wag. Dzięki ponownemu wykorzystaniu wyników pośrednich propagacja wsteczna zmniejsza tę złożoność do \(O(W)\).
Kluczowa obserwacja: Każdy gradient pośredni jest obliczany dokładnie raz i ponownie wykorzystywany we wszystkich kolejnych obliczeniach. Jest to programowanie dynamiczne zastosowane do różniczkowania.
Dla sieci z \(L\) warstwami i \(n\) neuronami w każdej warstwie:
- Przejście w przód: \(O(Ln^2)\) operacji
- Przejście wstecz: \(O(Ln^2)\) operacji
- Łącznie: \(O(Ln^2)\) — ten sam rząd złożoności co dla przejścia w przód!
1.7 Szczegółowy przykład: sieć dwuwarstwowa
Przeanalizujmy konkretny przykład sieci dwuwarstwowej (z jedną warstwą ukrytą).
Architektura:
- Warstwa wejściowa: 2 neurony (\(x \in \mathbb{R}^2\))
- Warstwa ukryta: 3 neurony z aktywacją sigmoidalną
- Warstwa wyjściowa: 1 neuron z aktywacją sigmoidalną
- Strata: błąd średniokwadratowy
Przykład treningowy: \(x = [0.5, 1.0]^T\), \(y = 0.8\)
Początkowe wagi i wyrazy wolne (wybrane losowo na potrzeby ilustracji):
1.7.1 Przejście w przód
Warstwa 1 (ukryta):
(gdzie \(\sigma(z) = \frac{1}{1 + e^{-z}}\) jest funkcją sigmoidalną)
Warstwa 2 (wyjściowa):
Strata:
1.7.2 Przejście wstecz
Błąd warstwy wyjściowej (\(\delta^{(2)}\)):
Dla straty MSE i aktywacji sigmoidalnej:
Błąd warstwy ukrytej (\(\delta^{(1)}\)):
Gradienty:
Dla \(W^{(2)}\):
Dla \(b^{(2)}\):
Dla \(W^{(1)}\):
Dla \(b^{(1)}\):
1.8 Implementacja w Pythonie
Poniżej znajduje się przejrzysta, zwektoryzowana implementacja propagacji wstecznej:
import numpy as np
class NeuralNetwork:
"""
A simple feedforward neural network with backpropagation.
"""
def __init__(self, layer_sizes, activation='relu'):
"""
Initialize network with random weights.
Parameters:
- layer_sizes: list of layer sizes [input_size, hidden1, hidden2, ..., output_size]
- activation: 'relu', 'sigmoid', or 'tanh'
"""
self.layer_sizes = layer_sizes
self.num_layers = len(layer_sizes)
self.activation_name = activation
# Initialize weights and biases with He initialization for ReLU
self.weights = []
self.biases = []
for i in range(1, self.num_layers):
# He initialization: good for ReLU
w = np.random.randn(layer_sizes[i], layer_sizes[i-1]) * np.sqrt(2.0 / layer_sizes[i-1])
b = np.zeros((layer_sizes[i], 1))
self.weights.append(w)
self.biases.append(b)
def activation(self, z):
"""Apply activation function."""
if self.activation_name == 'relu':
return np.maximum(0, z)
elif self.activation_name == 'sigmoid':
return 1 / (1 + np.exp(-np.clip(z, -500, 500)))
elif self.activation_name == 'tanh':
return np.tanh(z)
def activation_derivative(self, z):
"""Compute derivative of activation function."""
if self.activation_name == 'relu':
return (z > 0).astype(float)
elif self.activation_name == 'sigmoid':
s = self.activation(z)
return s * (1 - s)
elif self.activation_name == 'tanh':
return 1 - np.tanh(z)**2
def forward(self, x):
"""
Forward pass through the network.
Parameters:
- x: input vector of shape (input_size, 1)
Returns:
- activations: list of activations for each layer
- z_values: list of pre-activation values for each layer
"""
activations = [x]
z_values = []
a = x
for i in range(self.num_layers - 1):
z = self.weights[i] @ a + self.biases[i]
z_values.append(z)
# Use linear activation for output layer, activation function for hidden layers
if i == self.num_layers - 2: # Output layer
a = z # Linear output for regression
else:
a = self.activation(z)
activations.append(a)
return activations, z_values
def backward(self, x, y, activations, z_values):
"""
Backward pass: compute gradients using backpropagation.
Parameters:
- x: input vector
- y: true output
- activations: list from forward pass
- z_values: list from forward pass
Returns:
- weight_gradients: list of gradients for weights
- bias_gradients: list of gradients for biases
"""
weight_gradients = [None] * (self.num_layers - 1)
bias_gradients = [None] * (self.num_layers - 1)
# Output layer error (assuming MSE loss and linear output)
delta = activations[-1] - y
# Backpropagate through layers
for l in range(self.num_layers - 2, -1, -1):
# Compute gradients for this layer
weight_gradients[l] = delta @ activations[l].T
bias_gradients[l] = delta
# Propagate error to previous layer (if not input layer)
if l > 0:
delta = (self.weights[l].T @ delta) * self.activation_derivative(z_values[l-1])
return weight_gradients, bias_gradients
def train_step(self, x, y, learning_rate=0.01):
"""
Perform one training step: forward pass, backward pass, update weights.
Parameters:
- x: input vector (column vector)
- y: true output (scalar or column vector)
- learning_rate: step size for gradient descent
Returns:
- loss: mean squared error for this example
"""
# Ensure inputs are column vectors
x = x.reshape(-1, 1)
y = np.array(y).reshape(-1, 1)
# Forward pass
activations, z_values = self.forward(x)
# Compute loss (MSE)
loss = 0.5 * np.sum((activations[-1] - y)**2)
# Backward pass
weight_grads, bias_grads = self.backward(x, y, activations, z_values)
# Update weights and biases
for i in range(self.num_layers - 1):
self.weights[i] -= learning_rate * weight_grads[i]
self.biases[i] -= learning_rate * bias_grads[i]
return loss
def predict(self, x):
"""Make a prediction for input x."""
x = x.reshape(-1, 1)
activations, _ = self.forward(x)
return activations[-1]
# Example: Train on XOR problem
if __name__ == "__main__":
# XOR dataset
X = np.array([[0, 0], [0, 1], [1, 0], [1, 1]]).T # Shape: (2, 4)
y = np.array([[0], [1], [1], [0]]).T # Shape: (1, 4)
# Create network: 2 inputs, 4 hidden neurons, 1 output
nn = NeuralNetwork([2, 4, 1], activation='sigmoid')
# Training loop
epochs = 5000
print("Training on XOR problem...")
for epoch in range(epochs):
total_loss = 0
for i in range(4):
x_i = X[:, i:i+1]
y_i = y[:, i:i+1]
loss = nn.train_step(x_i, y_i, learning_rate=0.5)
total_loss += loss
if epoch % 1000 == 0:
print(f"Epoch {epoch:4d}, Loss: {total_loss:.6f}")
# Test predictions
print("\nPredictions after training:")
for i in range(4):
x_i = X[:, i]
pred = nn.predict(x_i)
print(f"Input: {x_i}, True: {y[0, i]:.1f}, Predicted: {pred[0, 0]:.4f}")
Wynik:
Training on XOR problem...
Epoch 0, Loss: 0.847213
Epoch 1000, Loss: 0.006891
Epoch 2000, Loss: 0.002456
Epoch 3000, Loss: 0.001339
Epoch 4000, Loss: 0.000889
Predictions after training:
Input: [0 0], True: 0.0, Predicted: 0.0187
Input: [0 1], True: 1.0, Predicted: 0.9795
Input: [1 0], True: 1.0, Predicted: 0.9801
Input: [1 1], True: 0.0, Predicted: 0.0213
1.9 Typowe funkcje aktywacji i ich pochodne
Propagacja wsteczna wymaga obliczania \(g'(z)\) dla każdej funkcji aktywacji. Oto najczęściej stosowane funkcje:
| Funkcja aktywacji | Definicja | Pochodna |
|---|---|---|
| ReLU | \(g(z) = \max(0, z)\) | \(g'(z) = \begin{cases} 1 & z > 0 \\ 0 & z \leq 0 \end{cases}\) |
| Sigmoidalna | \(g(z) = \frac{1}{1 + e^{-z}}\) | \(g'(z) = g(z)(1 - g(z))\) |
| Tanh | \(g(z) = \tanh(z) = \frac{e^z - e^{-z}}{e^z + e^{-z}}\) | \(g'(z) = 1 - \tanh^2(z)\) |
| Leaky ReLU | \(g(z) = \max(\alpha z, z)\), gdzie \(\alpha \approx 0.01\) | \(g'(z) = \begin{cases} 1 & z > 0 \\ \alpha & z \leq 0 \end{cases}\) |
| Softmax | \(g(z)_i = \frac{e^{z_i}}{\sum_{j} e^{z_j}}\) | \(\frac{\partial g(z)_i}{\partial z_j} = g(z)_i(\delta_{ij} - g(z)_j)\) |
Typowe zastosowania:
- ReLU / Leaky ReLU: Domyślny wybór dla warstw ukrytych; szybkie obliczenia, ograniczenie problemu zanikających gradientów
- Sigmoidalna / Tanh: Klasyczne funkcje aktywacji; przydatne dla wyjść o ograniczonym zakresie lub bramek w sieciach LSTM
- Softmax: Wyjścia klasyfikacji wieloklasowej; generuje rozkłady prawdopodobieństwa
1.10 Zanikające i eksplodujące gradienty
Jednym z wyzwań podczas uczenia głębokich sieci jest to, że gradienty mogą zanikać lub eksplodować podczas propagacji wstecz przez wiele warstw.
Zanikające gradienty: Przy stosowaniu funkcji sigmoidalnej lub tanh pochodne są ograniczone przez 1. Gdy mnożymy wiele takich czynników w równaniu \(\ref{eq:backprop_recursion}\), gradient może maleć wykładniczo:
Jeśli \(\|g'^{(l)}\| < 1\) oraz \(\|W^{(l)}\| < 1\), początkowe warstwy otrzymują bardzo małe gradienty i uczą się bardzo powoli.
Rozwiązania: - Stosowanie aktywacji ReLU (pochodna wynosi 0 lub 1) - Odpowiednia inicjalizacja wag (He, Xavier) - Normalizacja wsadowa - Połączenia rezydualne (połączenia skrótowe) - LSTM/GRU dla sieci rekurencyjnych
Eksplodujące gradienty: Odwrotny problem występuje, gdy gradienty rosną wykładniczo. Jest on częstszy w sieciach rekurencyjnych.
Rozwiązania: - Przycinanie gradientów - Staranna inicjalizacja wag - Mniejsze współczynniki uczenia
1.11 Perspektywa rachunku macierzowego
Propagację wsteczną można elegancko zapisać za pomocą rachunku macierzowego. Kluczowe jest śledzenie zmian straty przy zaburzaniu każdego parametru.
Dla skalarnej straty \(L\) i parametru macierzowego \(W\) gradient ma postać:
Systematycznie stosując regułę łańcuchową:
Ponieważ \(z^{(l)} = W^{(l)} a^{(l-1)} + b^{(l)}\):
To podejście naturalnie uogólnia się na warstwy splotowe, rekurencyjne i inne architektury.
1.12 Perspektywa grafu obliczeniowego
Współczesne biblioteki głębokiego uczenia (PyTorch, TensorFlow) reprezentują obliczenia jako skierowane grafy acykliczne (DAG). Każdy węzeł jest operacją, a krawędzie przedstawiają przepływ danych.
Przejście w przód: Przechodzimy przez graf od wejść do wyjść, obliczając wartości Przejście wstecz: Przechodzimy w odwrotnym kierunku, stosując regułę łańcuchową w każdym węźle
Ta abstrakcja umożliwia:
- Różniczkowanie automatyczne (autograd)
- Dynamiczne grafy obliczeniowe
- Łatwą implementację złożonych architektur
- Wydajne zrównoleglanie obliczeń na GPU
Opisany algorytm propagacji wstecznej jest dokładnie tym, co te biblioteki wykonują automatycznie po wywołaniu .backward() lub tape.gradient().
1.13 Aspekty praktyczne
Spadek gradientowy z minipartiami: Zamiast obliczać gradienty dla jednego przykładu, zwykle uśredniamy je po minipartii:
Zapewnia to: - Lepsze oszacowania gradientu (mniejszą wariancję) - Wydajne obliczenia zwektoryzowane - Efekt regularyzacji wynikający z szumu
Harmonogram współczynnika uczenia: Zaczynamy od większych współczynników uczenia i zmniejszamy je z czasem: - Spadek skokowy: zmniejszanie o określony czynnik co \(N\) epok - Spadek wykładniczy: \(\eta_t = \eta_0 e^{-kt}\) - Wyżarzanie kosinusowe: płynny spadek zgodny z krzywą kosinusową
Momentum i metody adaptacyjne: Nowoczesne optymalizatory rozszerzają podstawową metodę spadku gradientowego: - Momentum: Akumulowanie gradientów w czasie w celu wygładzenia aktualizacji - Adam: Adaptacyjne współczynniki uczenia dla poszczególnych parametrów, oparte na momentach gradientu - RMSprop: Skalowanie współczynników uczenia przez średnią kroczącą wielkości gradientów
Regularyzacja: Zapobieganie nadmiernemu dopasowaniu przez dodawanie kar: - Regularyzacja L2 (zanik wag): Dodanie \(\frac{\lambda}{2}\|\theta\|^2\) do straty - Dropout: Losowe zerowanie neuronów podczas uczenia - Normalizacja wsadowa: Normalizowanie aktywacji w obrębie każdej minipartii
1.14 Kontekst historyczny i znaczenie
Propagacja wsteczna zrewolucjonizowała uczenie maszynowe po jej spopularyzowaniu w latach 80. XX wieku 1. Chociaż podstawowa idea (zastosowanie reguły łańcuchowej) jest starsza, przełomowa praca Rumelharta, Hintona i Williamsa wykazała jej praktyczną skuteczność w uczeniu sieci wielowarstwowych 23.
Najważniejsze etapy: - Lata 60. i 70. XX wieku: Wczesne prace nad różniczkowaniem automatycznym i jego trybem wstecznym - 1986: Rumelhart, Hinton i Williams 1 popularyzują propagację wsteczną w sieciach neuronowych - Lata 90. XX wieku: Trudności związane z głębokimi sieciami i zanikającymi gradientami - 2006: Głębokie sieci przekonań Hintona ponownie wzbudzają zainteresowanie - 2012: AlexNet wykorzystuje propagację wsteczną i GPU, aby wygrać konkurs ImageNet, rozpoczynając erę głębokiego uczenia 4 - Obecnie: Propagacja wsteczna służy do uczenia sieci z miliardami parametrów (GPT, BERT itd.)
Elegancja algorytmu tkwi w jego prostocie: to jedynie systematyczne stosowanie reguły łańcuchowej. A jednak ta prosta idea umożliwia uczenie niezwykle złożonych modeli, które stanowią podstawę współczesnej sztucznej inteligencji 4.
1.15 Poza sieciami jednokierunkowymi
Choć skupiliśmy się na sieciach jednokierunkowych, propagacja wsteczna uogólnia się na inne architektury:
Splotowe sieci neuronowe (CNN): Propagacja wsteczna przez operacje splotu z wykorzystaniem splotów transponowanych
Rekurencyjne sieci neuronowe (RNN): Propagacja wsteczna w czasie (BPTT), z rozwinięciem sieci w kolejnych krokach czasowych
Transformery: Propagacja wsteczna przez mechanizmy samouwagi, normalizację warstwową i połączenia rezydualne
Grafowe sieci neuronowe: Przekazywanie i agregowanie komunikatów z propagacją wsteczną w strukturach grafowych
Podstawowa zasada pozostaje taka sama: wykonujemy przejście w przód, a następnie propagujemy gradienty wstecz za pomocą reguły łańcuchowej.
1.16 Podsumowanie
Propagacja wsteczna jest podstawowym narzędziem głębokiego uczenia, umożliwiającym wydajne obliczanie gradientów poprzez:
- Przejście w przód: Obliczanie wyjść i zapisywanie wartości pośrednich
- Przejście wstecz: Rekurencyjne obliczanie błędów za pomocą \(\delta^{(l)} = (W^{(l+1)})^T \delta^{(l+1)} \odot g'^{(l)}(z^{(l)})\)
- Obliczanie gradientów: Wykorzystanie \(\frac{\partial L}{\partial W^{(l)}} = \delta^{(l)} (a^{(l-1)})^T\)
- Aktualizację parametrów: Zastosowanie spadku gradientowego lub jego wariantów
Złożoność algorytmu \(O(W)\) (liniowa względem liczby wag) sprawia, że uczenie głębokich sieci jest wykonalne. W połączeniu z wydajnym sprzętem (GPU, TPU) i nowoczesnymi optymalizatorami propagacja wsteczna umożliwiła rewolucję głębokiego uczenia 23.
-
David E. Rumelhart, Geoffrey E. Hinton, and Ronald J. Williams. Learning representations by back-propagating errors. Nature, 323(6088):533–536, 1986. doi:10.1038/323533a0. ↩↩↩
-
Ian Goodfellow, Yoshua Bengio, and Aaron Courville. Deep Learning. MIT Press, 2016. URL: https://www.deeplearningbook.org/. ↩↩
-
Michael A. Nielsen. Neural Networks and Deep Learning. Determination Press, 2015. URL: http://neuralnetworksanddeeplearning.com/. ↩↩
-
Yann LeCun, Yoshua Bengio, and Geoffrey Hinton. Deep learning. Nature, 521(7553):436–444, 2015. doi:10.1038/nature14539. ↩↩