Trenowanie prawdziwej sieci na MNIST

W poprzednim artykule przyjrzeliśmy się wszystkim pojęciom stojącym za trenowaniem sieci neuronowej — przejściom w przód, funkcjom straty, propagacji wstecznej, spadkowi gradientu, współczynnikom uczenia, mini-batchom, zanikającym gradientom. Do wyjaśnienia mechaniki użyliśmy zabawkowego przykładu dopasowania prostej o 2 parametrach — mając wejście xx, przewidzieć y=2x+1y = 2x + 1. To była regresja: sieć produkuje jedną liczbę, która może przyjąć dowolną wartość na skali ciągłej — jak cena albo temperatura — a to, jak bardzo się mylimy, mierzyliśmy błędem średniokwadratowym (funkcją straty wybraną do tamtego zadania).

Tutaj zrobimy coś innego — klasyfikację. Zamiast jednej liczby sieć będzie wypuszczać prawdopodobieństwo dla każdej kategorii i wybierać najwyższe: spojrzeć na odręczną cyfrę i zdecydować, która to. Użyjemy klasycznego zbioru MNIST — 70 000 obrazów odręcznych cyfr od 0 do 9.

Najpierw zbudujemy wszystko od zera w NumPy — pisząc ręcznie przejście w przód, funkcję straty, propagację wsteczną i spadek gradientu — żebyś zobaczył każdy krok procesu treningowego. Potem pokażemy to samo w Kerasie, wysokopoziomowym frameworku, który robi to wszystko automatycznie w kilku linijkach kodu. Keras może działać na PyTorchu, TensorFlow albo JAX — te backendy biorą na siebie ciężką robotę (akcelerację na GPU, automatyczne liczenie gradientów), a Keras daje na wierzchu czysty interfejs. NumPy jest za wolny do prawdziwego treningu, ale idealny do zrozumienia, co się dzieje — a ten kod odwzorowuje wprost to, co Keras robi pod maską.

Zbiór danych

MNIST to kolekcja 70 000 odręcznych cyfr (0–9) — 60 000 do treningu i 10 000 do testów. Każdy obraz ma 28×28 pikseli, w odcieniach szarości. Na każdą cyfrę przypada mniej więcej 6 000 obrazów treningowych — łącznie 60 000 do treningu i 10 000 do testów.

Oto kilka przykładów:

Obrazy i etykiety są przechowywane jako tablice NumPy. Spójrzmy na surowe dane.

# 60,000 images, each a matrix of 28×28 pixels
>>> train_images.shape
(60000, 28, 28)

# A single image: 28 rows × 28 columns of pixel values (0–255)
>>> train_images[0].shape
(28, 28)

>>> train_images[0].dtype
dtype('uint8')

# 60,000 labels: first image is "5", second is "0", ...
>>> train_labels
array([5, 0, 4, ..., 5, 6, 8], dtype=uint8)

# 10,000 test images, same 28×28 matrices
>>> test_images.shape
(10000, 28, 28)

# 10,000 labels for test images
>>> test_labels
array([7, 2, 1, ..., 4, 5, 6], dtype=uint8)

Każda wartość w siatce 28×28 to intensywność piksela. Skoro każdy piksel jest zapisany na jednym bajcie (8 bitów), może reprezentować 28=2562^8 = 256 wartości — od 0 do 255. W surowych danych 0 oznacza czerń, a 255 biel — więc cyfry MNIST to białe kreski na czarnym tle. To nie jest specyfika MNIST — tak intensywność piksela przechowują praktycznie wszystkie obrazy cyfrowe. Obrazy kolorowe używają 3 bajtów na piksel (po jednym na czerwony, zielony i niebieski), ale MNIST jest w odcieniach szarości, więc jeden bajt wystarcza.

Ta siatka liczb 28×28 jest obrazem — każda liczba odwzorowuje się wprost na odcień szarości. Kliknij dowolny piksel poniżej i wpisz nową wartość, żeby zobaczyć to w praktyce. Przełącznikiem możesz przechodzić między oryginalną reprezentacją (biel na czerni) a widokiem odwróconym (czerń na bieli, który czyta się łatwiej):

OriginalInverted0 = white, 255 = black
train_images[0]
row 8, col 13
train_images[0][8][13] =

Zauważ, że wartości pikseli w powiększonej siatce nie zmieniają się przy przełączaniu — zawsze pokazują surowe dane (to, co faktycznie jest w tablicy). Przełącznik zmienia tylko to, jak te wartości odwzorowują się na kolory na ekranie. Piksel o wartości 255 w danych zawsze wynosi 255; po prostu renderuje się jako biały w trybie oryginalnym i czarny w odwróconym. Nie ma tu żadnej ukrytej struktury — obraz to te 784 liczby. Zmień liczbę, zmienisz piksel.

Każdy obraz ma etykietę — cyfrę, którą przedstawia (0–9). Razem te dwie tablice — obrazy i etykiety — to wszystko, z czego sieć będzie się uczyć. Zadanie jest proste: mając obraz 28×28, przewidzieć, która to cyfra. To problem klasyfikacji z 10 klasami — sieć, którą zbudujemy, nazywa się klasyfikatorem wieloklasowym. W klasyfikacji klasa to jedna z możliwych etykiet — jedna kategoria, którą model może wybrać. Mamy 10 klas (cyfry 0–9) z mniej więcej 6 000 przykładów treningowych na cyfrę (nie idealnie po równo — cyfra 1 ma 6 742 przykłady, a cyfra 5 tylko 5 421).

Od regresji do klasyfikacji

Przechodząc od zadania regresji z poprzedniego artykułu (y=2x+1y = 2x + 1) do klasyfikacji cyfr, będziemy potrzebować:

  1. Warstwy wyjściowej — zamiast jednego neuronu wypuszczającego jedną liczbę potrzebujemy 10 neuronów (po jednym na cyfrę), które dają rozkład prawdopodobieństwa. Wymaga to nowej funkcji aktywacji na warstwie wyjściowej: softmaxu.
  2. Funkcji straty — zamiast błędu średniokwadratowego, mówiącego, jak daleko jest liczba, potrzebujemy straty entropii krzyżowej, mówiącej, jak pewny był model co do poprawnej klasy.
  3. Gradientu — wzór na gradient zmienia się pod nową funkcję straty. Gradient entropii krzyżowej + softmaxu okazuje się mieć zaskakująco czystą postać.
  4. Aktywacji warstwy ukrytej — potrzebujemy nieliniowej funkcji aktywacji między warstwami, żeby sieć mogła uczyć się złożonych wzorców. Użyjemy ReLU, tej samej aktywacji co w przykładach neuronu i warstwy z poprzedniego artykułu.

Rdzeń algorytmu — przejście w przód, propagacja wsteczna, spadek gradientu, mini-batche — pozostaje ten sam. Przejdźmy przez każdy element.

Warstwa wyjściowa i softmax

W regresji sieć wypuszcza jedną liczbę. W klasyfikacji musi wybrać ze zbioru kategorii — w naszym przypadku jedną z 10 cyfr. Sieć musi powiedzieć „myślę, że to 2” z jakimś poziomem pewności.

Załatwiamy to, dając warstwie wyjściowej 10 neuronów — po jednym na cyfrę. Każdy neuron produkuje wynik zwany logitem (nazwa pochodzi od „log-odds” z regresji logistycznej, ale w praktyce znaczy po prostu „surowy wynik przed softmaxem”), wskazujący, jak silnie sieć wierzy, że wejściem jest ta cyfra. Wyższy wynik to większa pewność.

Przy 10 cyfrach nasza warstwa wyjściowa produkuje 10 logitów — po jednym na klasę. Żeby przykłady i wizualizacje były prostsze, w kodzie poniżej użyjemy tylko 3 klas (cyfry 0, 1, 2) — matematyka działa dokładnie tak samo, tylko liczb do oglądania jest mniej.

Dla danego obrazu wejściowego wyjście może wyglądać tak:

28×28neuralnetworklogits (3 output neurons)0121.5-0.34.2 ← highest

Obraz wchodzi, sieć przetwarza go przez swoje warstwy i wychodzą 3 liczby — po jednej na cyfrę. Cyfra 2 dostała najwyższy wynik (4.2), więc to jest predykcja sieci. W kodzie:

logits = [1.5, -0.3, 4.2]
#          0     1    2

# The network's guess: digit 2 (highest logit = 4.2)
# argmax returns the index of the largest value, not the value itself
prediction = np.argmax(logits)  # 2

Ale surowe logity nie są prawdopodobieństwami — mogą być dowolnymi liczbami, dodatnimi lub ujemnymi, i nie sumują się do 1. Żeby zamienić je w porządny rozkład prawdopodobieństwa, używamy funkcji softmax — jednej z najczęstszych operacji w uczeniu głębokim, obecnej wszędzie, od klasyfikatorów obrazów po LLM-y takie jak ChatGPT (gdzie zamienia surowe wyniki dla słów słownika w prawdopodobieństwo następnego tokena):

softmax(zi)=ezij=110ezj\text{softmax}(z_i) = \frac{e^{z_i}}{\sum_{j=1}^{10} e^{z_j}}

Dlaczego nazywa się „softmax”? Bo to łagodniejsza wersja maksimum. Twardy max po prostu wybrałby największą wartość i zignorował całą resztę — [0, 0, 1]. Softmax robi prawie to samo, gdy jeden logit dominuje, ale gdy logity są podobnie duże, pozostałe wartości też dostają sensowną wagę. Porównaj:

logitytwardy maxsoftmax
[1.0, 8.5, 1.0][0, 1, 0][0.1%, 99.8%, 0.1%]
[3.0, 3.5, 3.0][0, 1, 0][24.4%, 51.2%, 24.4%]
[3.0, 3.0, 3.0]nieokreślony (remis)[33.3%, 33.3%, 33.3%]

Twardy max daje odpowiedź binarną — zwycięzca bierze wszystko. Softmax daje gładki rozkład, który zmienia się w sposób ciągły wraz z wejściami. Gdy model jest niepewny (logity blisko siebie), softmax to odzwierciedla. Gdy model jest pewny (jeden logit znacznie większy), softmax zbliża się do twardego maksimum. To gładkie zachowanie jest też tym, co sprawia, że propagacja wsteczna działa — przez softmax da się liczyć gradienty, bo jest różniczkowalny wszędzie.

Twardy max nie ma użytecznego gradientu (wyjście jest stałe pomiędzy skokami), a softmax nigdy nie produkuje dokładnie 0 ani 1 — ponieważ ex>0e^x > 0 dla dowolnego xx, każda klasa zawsze dostaje jakieś prawdopodobieństwo — więc gradient jest zawsze określony i zawsze niezerowy. Widać to w widgecie poniżej — spróbuj ustawić dwa pierwsze logity na 0, a trzeci na dużą liczbę. Nawet gdy jedna klasa dominuje, pozostałe nigdy nie sięgają dokładnie 0%.

Softmax działa w trzech krokach (na naszych przykładowych logitach [1.5, -0.3, 4.2]):

  1. Podnieś ee do potęgi każdego logitu — skoro ex>0e^x > 0 dla dowolnego xx, zamienia to każdą wartość, nawet ujemną, w liczbę dodatnią: e1.5=4.48e^{1.5} = 4.48, e0.3=0.74e^{-0.3} = 0.74, e4.2=66.69e^{4.2} = 66.69
  2. Zsumuj wszystkie dodatnie wartości4.48+0.74+66.69=71.914.48 + 0.74 + 66.69 = 71.91
  3. Podziel każdą wartość przez tę sumę66.69/71.91=0.92766.69 / 71.91 = 0.927, 4.48/71.91=0.0634.48 / 71.91 = 0.063 itd.

To normalizuje listę tak, że sumuje się dokładnie do 1 — porządny rozkład prawdopodobieństwa.

logitssoftmaxprobabilities

Poza różniczkowalnością softmax ma jeszcze jedną ważną własność: efekt wzmocnienia funkcji wykładniczej. To właśnie dlatego softmax używa exe^x, a nie po prostu dzieli surowe logity przez ich sumę. Oryginalne logity (1.5, -0.3, 4.2) różnią się zaledwie o kilka punktów. Ale funkcja wykładnicza zamienia różnice addytywne w multiplikatywne: e4.2=66.7e^{4.2} = 66.7, podczas gdy e1.5=4.5e^{1.5} = 4.5 i e0.3=0.7e^{-0.3} = 0.7. Różnica logitów wynosząca 2.7 (między 4.2 a 1.5) staje się 15-krotną różnicą po eksponencjacji. Po podzieleniu przez sumę cyfra 2 kończy z 92.7% prawdopodobieństwa — wykładnicza zamienia „trochę wyżej” w „prawie na pewno”. Dokładnie tego chcemy od klasyfikatora: pewnej predykcji, gdy jedna klasa punktuje wyraźnie wyżej niż pozostałe.

Tym efektem wzmocnienia możesz sterować parametrem temperatury TT. Wzór staje się taki:

softmax(zi)=ezi/Tjezj/T\text{softmax}(z_i) = \frac{e^{z_i / T}}{\sum_{j} e^{z_j / T}}

Dzielenie przez TT przed eksponencjacją skaluje różnice między logitami. Spróbuj przeciągnąć suwak T:

logitssoftmaxprobabilities
T =1.0
  • T → 0 (niska temperatura) — różnice zostają wzmocnione, rozkład robi się ostry. Najwyższy logit dostaje prawie 100%. To zbliża się do twardego maksimum.
  • T = 1 — standardowy softmax. Dzielenie przez 1 nic nie zmienia, więc jest to równoważne brakowi TT.
  • T → ∞ (wysoka temperatura) — różnice zostają spłaszczone, wszystkie logity stają się podobne, a rozkład zbliża się do jednostajnego (po 33.3%).

Jeśli używałeś ChatGPT albo innych LLM-ów, widziałeś ten parametr — to ten sam suwak „temperature”. LLM-y używają softmaxu, żeby zamienić swoje logity wyjściowe w rozkład prawdopodobieństwa nad następnym słowem. Niska temperatura sprawia, że model prawie zawsze wybiera najbardziej prawdopodobne słowo (deterministycznie, powtarzalnie). Wysoka rozprasza prawdopodobieństwo na wiele słów (kreatywnie, czasem bez sensu). Parametr temperatury steruje tym kompromisem.

Tak zaimplementowalibyśmy softmax w Pythonie:

def softmax(logits):
    # Subtract max for numerical stability (doesn't change the result,
    # but prevents overflow when computing e^z for large values)
    exp = np.exp(logits - np.max(logits))
    return exp / np.sum(exp)

probs = softmax(logits)
# [0.063, 0.010, 0.927]
#   0      1      2
# The highest probability is 92.7% for digit 2

Zwróć uwagę, że odejmujemy też max. Musimy to zrobić, bo softmax liczy ezie^{z_i}, a funkcja wykładnicza rośnie ekstremalnie szybko. Jeśli logit wynosi 1000, to e1000e^{1000} przepełnia się do nieskończoności w arytmetyce zmiennoprzecinkowej:

>>> import numpy as np
>>> np.exp(1000)
inf                    # overflow — can't compute

>>> np.exp(1000) / (np.exp(1) + np.exp(2) + np.exp(1000))
nan                    # infinity / infinity = undefined

Odjęcie maksymalnego logitu od wszystkich wartości przed eksponencjacją przesuwa największą wartość do 0, a wszystkie pozostałe czyni ujemnymi. Względna kolejność jest zachowana — e0=1e^0 = 1 nadal jest największe, bo eujemnee^{\text{ujemne}} jest zawsze mniejsze od 1 — więc prawdopodobieństwa wychodzą takie same:

>>> logits = [1, 2, 1000]
>>> max(logits)
1000

>>> [z - max(logits) for z in logits]
[-999, -998, 0]        # largest becomes 0, others become negative

>>> np.exp([-999, -998, 0])
array([0., 0., 1.])    # e⁰ = 1 is the largest (e^negative is always < 1), no overflow

Tu widać, jak matematycznie wychodzą te same prawdopodobieństwa z odejmowaniem i bez:

ezimax(z)jezjmax(z)=ezi/emax(z)jezj/emax(z)=ezijezj\frac{e^{z_i - \max(z)}}{\sum_j e^{z_j - \max(z)}} = \frac{e^{z_i} / e^{\max(z)}}{\sum_j e^{z_j} / e^{\max(z)}} = \frac{e^{z_i}}{\sum_j e^{z_j}}

Czynnik emax(z)e^{\max(z)} się skraca, ale uniknęliśmy liczenia niebezpiecznie dużych potęg. To standardowa sztuczka na stabilność numeryczną, którą zobaczysz w każdej implementacji softmaxu.

ReLU: funkcja aktywacji dla warstw ukrytych

Omówiliśmy już jedną funkcję aktywacji — softmax na warstwie wyjściowej, zamieniający logity w prawdopodobieństwa. Ale potrzebujemy też funkcji aktywacji na warstwach ukrytych. Wytrenowany model z poprzedniego artykułu (y=2x+1y = 2x + 1) był pojedynczą operacją liniową — bez funkcji aktywacji, bez warstw ukrytych.

Wyjaśniono tam też, dlaczego układanie warstw bez funkcji aktywacji jest bez sensu: łańcuch operacji liniowych zapada się do jednej operacji liniowej. Żeby uczyć się złożonych wzorców, a nie samych prostych, potrzebujemy nieliniowości między warstwami. Poprzedni artykuł przedstawił funkcje aktywacji jako rozwiązanie, pokazując kilka opcji w widgecie neuronu (sigmoid, perceptron, ReLU).

Użyjemy ReLU (Rectified Linear Unit), najczęstszego wyboru we współczesnych sieciach:

f(x)=max(0,x)f(x) = \max(0, x)

Przepuszcza wartości dodatnie bez zmian, a ujemne obcina do zera. Przeciągnij suwaki poniżej — gdy suma ważona jest dodatnia (zielone), wartość przechodzi. Gdy jest ujemna (czerwone), wyjście zostaje obcięte do 0:

2.0
0.8

Zachowanie widać przy poruszaniu oboma suwakami — liczy się suma ważona w × x, a nie poszczególne wartości. Spróbuj ustawić i wejście, i wagę na liczby ujemne: iloczyn jest dodatni, więc ReLU go przepuszcza. Obcina do zera tylko wtedy, gdy sam iloczyn jest ujemny.

ReLU świetnie sprawdza się też przy uczeniu przez propagację wsteczną. Pochodna funkcji aktywacji pojawia się wprost w iloczynie z reguły łańcuchowej, gdy liczymy gradient straty. W poprzednim artykule gradient dla wagi warstwy ukrytej wyglądał tak:

dw11 = x1 * relu_deriv(z1) * v1 * 2 * error
#            ↑ activation derivative — one factor in the chain

Gradient straty to iloczyn pochodnych lokalnych wzdłuż ścieżki. relu_deriv jest jednym z tych czynników — więc gdy wynosi 1, gradient przechodzi bez zmian. Gdy wynosi 0, zeruje cały iloczyn i neuron nie uczy się z tego wejścia — to problem umierającego ReLU. Ale w praktyce jest on znacznie mniej szkodliwy niż przypadłość sigmoidy: przy ReLU tylko część neuronów obumiera, a reszta niesie gradient w pełnej sile. Przy sigmoidzie każdy neuron zmniejsza gradient i przestaje się uczyć — nazywa się to saturacją, a efekt kumuluje się przez warstwy. Ten problem zobaczymy szczegółowo, gdy dalej w artykule dojdziemy do normalizacji.

Dlaczego sigmoid jest gorszy od ReLU dla gradientów?

Pochodna sigmoidy to σ(z)(1σ(z))\sigma(z)(1 - \sigma(z)). Skoro sigmoid daje wartość pp między 0 a 1, jest to p×(1p)p \times (1 - p) — co jest maksymalne przy p=0.5p = 0.5 (przy z=0z = 0): 0.5×0.5=0.250.5 \times 0.5 = 0.25. Gdy zz oddala się od 0 w którąkolwiek stronę, sigmoid saturuje ku 0 albo 1, a pochodna maleje ku 0.

Pochodna sigmoidy jest więc zawsze między 0 a 0.25 — czyli zmniejsza gradient na każdej warstwie. Już po 5 warstwach: 0.2550.0010.25^5 \approx 0.001. Gradient skurczył się 1000×, a wczesne warstwy ledwo się uczą. ReLU całkowicie tego unika — jej pochodna wynosi 1 dla aktywnych neuronów, więc gradient przechodzi w pełnej sile.

Nasza sieć ma zatem dwie różne funkcje aktywacji pełniące dwie różne role: ReLU w warstwach ukrytych (wprowadza nieliniowość, żeby sieć mogła uczyć się złożonych wzorców) i softmax na warstwie wyjściowej (zamienia surowe wyniki w prawdopodobieństwa).

Funkcja straty

Teraz potrzebujemy funkcji straty. W poprzednim artykule użyliśmy błędu średniokwadratowego — kwadratu różnicy między predykcją a celem, uśrednionego po wszystkich punktach danych. Miało to sens dla regresji: predykcja była jedną liczbą, cel był jedną liczbą i chcieliśmy, żeby były blisko.

Dla klasyfikacji używamy straty entropii krzyżowej. Idea jest prosta: weź ujemny logarytm prawdopodobieństwa, które model przypisał poprawnej klasie.

loss=log(pcorrect class)\text{loss} = -\log(p_{\text{correct class}})

Im niższe prawdopodobieństwo, tym wyższa strata:

  • Model mówi, że jest 95% szans na trafienie: log(0.95)=0.05-\log(0.95) = 0.05 — niska strata, dobra predykcja
  • Model mówi 60%: log(0.60)=0.51-\log(0.60) = 0.51 — umiarkowana strata, za mała pewność
  • Model mówi 1%: log(0.01)=4.6-\log(0.01) = 4.6 — bardzo wysoka strata, prawie kompletna pomyłka

Dlaczego logarytm? Ma dokładnie te własności, których potrzebujemy od funkcji straty:

  • Skoro log(1)=0\log(1) = 0 — jeśli model przypisze 100% poprawnej klasie, strata wynosi zero. Idealna predykcja, nie ma czego karać.
  • Skoro log(x)\log(x) \to -\infty przy x0x \to 0 — kara nie ma sufitu. Im pewniejszy jest model co do złej odpowiedzi, tym wyżej idzie strata, bez ograniczeń. Tworzy to niezwykle silny sygnał do naprawiania pewnych siebie pomyłek.
  • Krzywa jest stroma blisko 0 i płaska blisko 1 — ponieważ pochodna (1/p-1/p) jest duża, gdy pp jest małe, i mała, gdy pp jest bliskie 1, gradient automatycznie robi duże korekty, gdy model się myli, i delikatne pchnięcia, gdy jest już blisko.

Rozwińmy ten ostatni punkt. Dwa wykresy poniżej pokazują log(p)\log(p) i log(p)-\log(p) obok siebie. Spójrz na zielony wykres log(p)-\log(p) — to jego używamy jako funkcji straty:

  • Gdy pp jest blisko 0 (model się myli), krzywa strzela stromo w górę — mała zmiana prawdopodobieństwa powoduje dużą zmianę straty.
  • Gdy pp jest blisko 1 (model ma rację), krzywa się wypłaszcza — strata prawie się nie zmienia.

Teraz porównaj z niebieskim wykresem log(p)\log(p) — to lustrzane odbicie: stromo blisko 1, płasko blisko 0, co byłoby złym zachowaniem dla funkcji straty. Dlatego ją odwracamy — znak minus obraca krzywą tak, żeby gradient był najsilniejszy dokładnie tam, gdzie potrzebujemy go najbardziej.

log(p)
steep near 1, flat near 0
−log(p) ← what we use
steep near 0, flat near 1

Mile widziany efekt uboczny znaku minus: wiemy, że log\log liczby między 0 a 1 jest zawsze ujemny, np. log(0.6)=0.51\log(0.6) = -0.51, więc strata też byłaby ujemna, a powinna być dodatnia. Znak minus zamienia ją w wartość dodatnią: log(0.6)=0.51-\log(0.6) = 0.51.

Ta stromość wpływa wprost na propagację wsteczną. Pochodna log(p)-\log(p) to 1/p-1/p. Gdy model bardzo się myli (p=0.01p = 0.01), gradient wynosi 1/0.01=100-1/0.01 = -100 — silny sygnał do korekty. Gdy jest już blisko (p=0.95p = 0.95), gradient to zaledwie 1/0.95=1.05-1/0.95 = -1.05 — delikatne pchnięcie.

Przeciągnij suwaki poniżej, żeby zobaczyć, jak zmienia się strata przy zmianie prawdopodobieństw — obserwuj żółtą kropkę przesuwającą się po krzywej log-\log:

loss = −log(pcorrect class)
True label: 2
Loss: 1.079
Gradient magnitude:
Probability distribution
Loss vs probability for true class

Spróbuj przeciągnąć suwak cyfry 2 (prawdziwej klasy) w prawo — gdy jej prawdopodobieństwo zbliża się do 100%, strata spada prawie do zera. Przeciągnij w lewo — gdy prawdopodobieństwo spada, strata strzela stromo w górę. Ta stromość oznacza, że gradient też jest duży — więc model dostaje silny sygnał do korekty, gdy pewnie się myli, i delikatne pchnięcie, gdy jest już blisko poprawnej odpowiedzi.

W Pythonie funkcja straty wygląda tak. Bierze przewidziane przez model prawdopodobieństwa i prawdziwą etykietę (indeks poprawnej klasy — 0, 1 albo 2) i zwraca log-\log prawdopodobieństwa przypisanego tej klasie:

def cross_entropy_loss(probs, label):
    # label is the index of the correct class
    p_correct = probs[label]       # e.g. probs[2] = 0.94
    return -np.log(p_correct)      # -log(0.94) = 0.062

# Model is confident and correct → low loss
cross_entropy_loss([0.05, 0.01, 0.94], label=2)   # 0.062

# Model is uncertain → moderate loss
cross_entropy_loss([0.30, 0.30, 0.40], label=2)    # 0.916

# Model is confident but wrong → high loss
cross_entropy_loss([0.80, 0.15, 0.05], label=2)    # 2.996

Strata a trafność

Podczas treningu śledzimy zarówno stratę, jak i trafność — są skorelowane, ale to nie to samo.

Trafność mierzy ułamek predykcji, które były poprawne. Jest binarna i nie dba o pewność — każda predykcja jest albo dobra, albo zła, bez punktów częściowych. Predykcja 0.51 dla właściwej klasy liczy się jako poprawna, tak samo jak 0.99 — obie dorzucają to samo +1 do licznika trafności. Ale strata jest bardzo różna: 0.67 dla pierwszej, 0.01 dla drugiej.

Strata (entropia krzyżowa) mierzy, ile prawdopodobieństwa model ulokował na poprawnej odpowiedzi — im mniej prawdopodobieństwa, tym wyższa kara. To wartość ciągła, oddająca jakość predykcji, a nie tylko to, czy była trafna. Strata jest ciągła — obchodzi ją, ile prawdopodobieństwa ulokowałeś na właściwej klasie, a nie tylko to, czy było ono najwyższe. Dwa modele mogą mieć tę samą trafność, ale bardzo różną stratę — jeden ledwo ma rację, drugi ma rację z przekonaniem.

Oto jak wygląda to w prawdziwym logu treningowym:

Epoch 1/10  — accuracy: 0.5117 — loss: 1.7934 — val_accuracy: 0.7520 — val_loss: 1.3155
Epoch 2/10  — accuracy: 0.7806 — loss: 1.0726 — val_accuracy: 0.8377 — val_loss: 0.8382
Epoch 5/10  — accuracy: 0.8627 — loss: 0.5560 — val_accuracy: 0.8798 — val_loss: 0.4901

W epoce 1 accuracy: 0.5117 oznacza, że model przewidział poprawną cyfrę dla 51% obrazów treningowych — dla każdego obrazu klasa o najwyższym prawdopodobieństwie jest porównywana z prawdziwą etykietą i liczony jest ułamek trafień. To już sporo powyżej 10% odniesienia dla losowego zgadywania spośród 10 klas, ale wciąż błąd na połowie obrazów.

Strata 1.79 mówi więcej — oznacza, że model przypisuje poprawnej klasie średnio niskie prawdopodobieństwo. Może to wynikać z dwóch rzeczy: model może przewidywać właściwą klasę, ale z niską pewnością (powiedzmy 0.3 zamiast 0.9), albo — gorzej — może być pewny i w błędzie, lokując większość prawdopodobieństwa na złej klasie. Wysoka strata jest zwykle zdominowana przez ten drugi przypadek: kilka pewnych siebie pomyłek wnosi do straty znacznie więcej niż wiele poprawnych predykcji o niskiej pewności.

Różnica między trafnością a stratą oznacza, że nie zawsze poruszają się razem:

  • Strata spada, trafność stoi — model staje się pewniejszy w predykcjach, które i tak robił poprawnie. Na przykład jeśli model już przewiduje „7” dla obrazu siódemki, ale jego pewność rośnie z 0.4 do 0.9, trafność się nie zmienia (już była poprawna), za to strata wyraźnie spada.
  • Trafność rośnie i strata też rośnie — model zdobywa więcej poprawnych predykcji, ale staje się nadmiernie pewny na tych błędnych. Wyobraź sobie, że model naprawia 5 obrazów, które wcześniej mylił (trafność się poprawia), ale jednocześnie staje się bardzo pewny (0.95) na 2 obrazach, które myli. Strata z tych 2 pewnych pomyłek może przeważyć poprawę z 5 nowych trafień.
  • Obie stoją w miejscu — model utknął. Wagi się zmieniają, ale predykcje nie są istotnie inne. Zdarza się to często, gdy współczynnik uczenia jest za mały albo model osiągnął swoją pojemność.

W praktyce przy zdrowym treningu obie metryki zwykle poprawiają się razem — log powyżej jest typowym przykładem. Gdy się rozjeżdżają, to sygnał, że dzieje się coś ciekawego (albo problematycznego).

Gradient

W poprzednim artykule widzieliśmy, że trening wymaga policzenia gradientu straty względem każdego parametru — pochodnej, która mówi, w którą stronę pchnąć każdą wagę, żeby zmniejszyć błąd. Dla naszego przykładu z dopasowaniem prostej pokazaliśmy, jak doszliśmy do wzoru na gradient MSE. Teraz potrzebujemy gradientu straty entropii krzyżowej względem logitów (surowych wyników przed softmaxem).

Oto jak wygląda pojedynczy neuron warstwy wyjściowej — logit zz to suma ważona przed zastosowaniem funkcji aktywacji:

x₁0.8x₂0.5w₁=0.3w₂=−0.6Σ + b−0.06b = 0logit z−0.06softmaxpᵢ

Zauważ, że sam softmax nie ma parametrów do uczenia — to tylko funkcja przekształcająca logity w prawdopodobieństwa. Uczone wagi siedzą w neuronie, który liczy sumę ważoną i produkuje logit. Gdy mamy już gradient względem logitów, płynie on dalej wstecz przez te wagi i w warstwy ukryte, aktualizując po drodze wszystko.

Wzór na MSE, do którego doszliśmy w poprzednim artykule, miał czytelną strukturę: dw = 2 * mean(error * x) — błąd razy wejście, uśrednione po przykładach. Każdy element wziął się z reguły łańcuchowej zastosowanej do funkcji straty. Teraz musimy zrobić to samo dla entropii krzyżowej w połączeniu z softmaxem. Przejdziemy przez to w trzech częściach:

  1. Gradient przez softmax (warstwa wyjściowa) — wyprowadzić gradient entropii krzyżowej + softmaxu względem logitów i dojść do czystego wzoru py\mathbf{p} - \mathbf{y}
  2. Gradient przez ReLU (warstwa ukryta) — pokazać, jak gradient przepływa przez funkcję aktywacji warstwy ukrytej
  3. Złożenie wszystkiego — spiąć obie części w pełne przejście wstecz

Pełny łańcuch w przód to: wejścia → warstwa ukryta (wagi + ReLU) → logity → softmax → prawdopodobieństwa → entropia krzyżowa → strata. Przejście wstecz idzie w odwrotną stronę — zaczynamy od straty i stosujemy regułę łańcuchową z powrotem przez entropię krzyżową, softmax, a potem warstwy ukryte.

Matematyka poniżej jest bardziej rozbudowana niż gradient MSE z poprzedniego artykułu — żeby użyć wyniku, nie trzeba śledzić każdego kroku. Jeśli wolisz, możesz przeskoczyć do końcowego wzoru i kodu. Ale jeśli chcesz zobaczyć, skąd się bierze, oto on krok po kroku.

Gradient przez softmax (warstwa wyjściowa)

Krok 1: pochodna straty. Strata to L=log(pc)L = -\log(p_c), gdzie cc jest poprawną klasą. Pochodną log-\log już znamy: to 1/p-1/p. Zatem:

Lpc=1pc\frac{\partial L}{\partial p_c} = -\frac{1}{p_c}

Krok 2: pochodna softmaxu. Mamy już pochodną straty względem pcp_c. Ale potrzebujemy pochodnej względem logitów ziz_i — bo to je faktycznie produkują wagi warstwy (jak pokazuje diagram powyżej). Żeby zasypać tę lukę, musimy wiedzieć: jak softmax zamienia zmianę ziz_i w zmianę pcp_c?

Softmax to pi=ezikezkp_i = \frac{e^{z_i}}{\sum_k e^{z_k}}. Korzystając z reguły ilorazu, mamy dwa przypadki:

Jeśli i=ci = c (pchamy logit własnej klasy):

pczc=ezckezkezcezc(kezk)2=pcpc2=pc(1pc)\frac{\partial p_c}{\partial z_c} = \frac{e^{z_c} \cdot \sum_k e^{z_k} - e^{z_c} \cdot e^{z_c}}{(\sum_k e^{z_k})^2} = p_c - p_c^2 = p_c(1 - p_c)

Wygląda to jak pochodna sigmoidy — i tak jest. Każde wyjście softmaxu zachowuje się lokalnie jak sigmoid.

Jeśli ici \neq c (pchamy inny logit):

pczi=0ezcezi(kezk)2=pcpi\frac{\partial p_c}{\partial z_i} = \frac{0 - e^{z_c} \cdot e^{z_i}}{(\sum_k e^{z_k})^2} = -p_c \cdot p_i

Pierwszy składnik licznika to 0, bo ezce^{z_c} nie zależy od ziz_i. Zwiększenie jednego logitu zawsze zmniejsza pozostałe prawdopodobieństwa — muszą sumować się do 1.

Krok 3: reguła łańcuchowa. Pomnóż obie pochodne:

  • Dla poprawnej klasy (i=ci = c): Lzc=1pcpc(1pc)=(1pc)=pc1\frac{\partial L}{\partial z_c} = -\frac{1}{p_c} \cdot p_c(1 - p_c) = -(1 - p_c) = p_c - 1
  • Dla błędnej klasy (ici \neq c): Lzi=1pc(pcpi)=pi\frac{\partial L}{\partial z_i} = -\frac{1}{p_c} \cdot (-p_c \cdot p_i) = p_i

Wynik

Po całym tym rachunku składniki pięknie się skracają. Gradient straty entropii krzyżowej w połączeniu z softmaxem, dla każdego neuronu wyjściowego ii, wynosi:

losszi={pi1jesˊli i=poprawna klasapiw przeciwnym razie\frac{\partial \text{loss}}{\partial z_i} = \begin{cases} p_i - 1 & \text{jeśli } i = \text{poprawna klasa} \\ p_i & \text{w przeciwnym razie} \end{cases}

Jak liczymy to w praktyce? Za pomocą y\mathbf{y}, wektora one-hot — samych zer z jedynką na poprawnej klasie. Ponieważ yi=1y_i = 1 dla poprawnej klasy i yi=0y_i = 0 dla całej reszty, odjęcie y\mathbf{y} od p\mathbf{p} daje pi1p_i - 1 dla poprawnej klasy i pi0=pip_i - 0 = p_i dla pozostałych — dokładnie ten wzór kawałkami z góry:

lossz=py\frac{\partial \text{loss}}{\partial \mathbf{z}} = \mathbf{p} - \mathbf{y}

W Pythonie to po prostu:

grad_z = probs - one_hot_label  # gradient w.r.t. logits

I tyle — gradient względem logitów to po prostu różnica między tym, co model przewidział, a tym, jaka była odpowiedź. Prawdopodobieństwa minus cel.

Zobaczmy, co to znaczy, na konkretnym przykładzie dla 3 klas. Jeśli model daje prawdopodobieństwa [0.06, 0.01, 0.93], a poprawną klasą jest 2:

  • Cel (one-hot) to [0, 0, 1] — całe prawdopodobieństwo powinno być na klasie 2
  • Gradient to [0.06 − 0, 0.01 − 0, 0.93 − 1] = [0.06, 0.01, −0.07]

Znak mówi o kierunku: klasa 2 dostaje ujemny gradient (−0.07), co znaczy „pchnij ten logit w górę, żeby zwiększyć jego prawdopodobieństwo”. Klasy 0 i 1 dostają gradienty dodatnie, czyli „pchnij te logity w dół”. Wielkość mówi o ile — klasa 0 (0.06) potrzebuje większej korekty niż klasa 1 (0.01), bo więcej prawdopodobieństwa do niej wyciekło.

Zauważ, że py\mathbf{p} - \mathbf{y} jest gradientem względem logitów, nie wag. Pamiętaj, neuron wyjściowy liczy z=Wx+bz = W\mathbf{x} + b (logit), a potem softmax zamienia go w prawdopodobieństwo. Pełny łańcuch od straty do wag ma więc dwie części:

LW=LzpyzWx\frac{\partial L}{\partial W} = \underbrace{\frac{\partial L}{\partial z}}_{\mathbf{p} - \mathbf{y}} \cdot \underbrace{\frac{\partial z}{\partial W}}_{\mathbf{x}}

Pierwsza część (py\mathbf{p} - \mathbf{y}) to to, co właśnie wyprowadziliśmy — jak strata zmienia się wraz z logitami. Druga część (x\mathbf{x}) to pochodna z=Wx+bz = W\mathbf{x} + b względem WW, czyli po prostu wejście. Ich przemnożenie daje gradient wag:

grad_z = probs - one_hot_label       # part 1: loss → logits (p - y)
grad_W = np.outer(grad_z, x)         # part 1 × part 2: logits → weights
grad_b = grad_z                       # bias gradient (same as grad_z)
grad_input = W.T @ grad_z            # gradient to pass to the previous layer

grad_input = W.T @ grad_z to sygnał gradientu przekazywany do poprzedniej warstwy. Ta warstwa robi potem to samo — tylko z ReLU zamiast softmaxu.

Gradient przez ReLU (warstwa ukryta)

Warstwa ukryta liczy z=Wx+bz = W\mathbf{x} + b, a potem stosuje ReLU: a=max(0,z)a = \max(0, z). Wyprowadziliśmy to w poprzednim artykule — gdy gradient przychodzi z następnej warstwy (grad_output), reguła łańcuchowa mnoży go przez pochodną ReLU:

Lz=grad_output×relu(z)=grad_output×{1jesˊli z>00jesˊli z0\frac{\partial L}{\partial z} = \text{grad\_output} \times \text{relu}'(z) = \text{grad\_output} \times \begin{cases} 1 & \text{jeśli } z > 0 \\ 0 & \text{jeśli } z \le 0 \end{cases}

Jeśli neuron był aktywny (z>0z > 0), gradient przechodzi bez zmian. Jeśli był nieaktywny (z0z \le 0), gradient zostaje wyzerowany. Potem, tak jak w warstwie wyjściowej, kontynuujemy łańcuch, żeby dostać gradienty wag:

grad_z = grad_output * (z > 0)       # multiply by ReLU derivative (0 or 1)
grad_W = np.outer(grad_z, x)         # chain rule: gradient for weights
grad_b = grad_z                       # gradient for biases
grad_input = W.T @ grad_z            # pass to the previous layer

Złożenie wszystkiego

Pełne przejście wstecz przechodzi łańcuchem przez każdą warstwę, zaczynając od straty:

  1. Warstwa wyjściowa: grad_z=py\text{grad\_z} = \mathbf{p} - \mathbf{y} → policz grad_W, grad_b, przekaż grad_input wstecz
  2. Warstwa ukryta: odbierz grad_input, pomnóż przez pochodną ReLU → policz grad_W, grad_b, przekaż grad_input wstecz
  3. Powtórz dla ewentualnych dodatkowych warstw ukrytych

Schemat jest ten sam na każdej warstwie — różni się tylko pochodna aktywacji (softmax vs ReLU). Oto pełne przejście wstecz dla naszej sieci (jedna warstwa ukryta + warstwa wyjściowa):

# Forward pass (for reference)
h = relu(W1 @ x + b1)          # hidden layer: inputs → ReLU
z = W2 @ h + b2                 # output layer: hidden → logits
p = softmax(z)                  # softmax: logits → probabilities
loss = -np.log(p[label])        # cross-entropy loss

# Backward pass: chain rule from loss back to weights
# 1. Output layer gradient (softmax + cross-entropy)
grad_z2 = p.copy()
grad_z2[label] -= 1             # p - y

grad_W2 = np.outer(grad_z2, h)  # weight gradient
grad_b2 = grad_z2               # bias gradient
grad_h = W2.T @ grad_z2         # pass gradient to hidden layer

# 2. Hidden layer gradient (ReLU)
grad_z1 = grad_h * (z1 > 0)     # multiply by ReLU derivative

grad_W1 = np.outer(grad_z1, x)  # weight gradient
grad_b1 = grad_z1               # bias gradient

# 3. Update all weights
W2 -= lr * grad_W2
b2 -= lr * grad_b2
W1 -= lr * grad_W1
b1 -= lr * grad_b1

Dokładnie to zaimplementują nasze HiddenLayer.backward() i OutputLayer.backward() w kodzie NumPy poniżej — te same kroki, tylko opakowane w klasy.

Widget poniżej uruchamia dokładnie tę pętlę na maleńkiej sieci — namiastka obrazu 2×2 zasilająca 2-neuronową warstwę ukrytą i wyjście z 3 klasami. Wybierz wejście, podaj poprawną cyfrę i patrz, jak wypełnia się przejście wstecz: błąd wyjścia grad_z2 = p − y, błąd warstwy ukrytej grad_z1 oraz macierze gradientów wag grad_W2 i grad_W1. Naciśnij Apply update, a strata spadnie — ten sam krok W -= lr * grad_W, tylko uwidoczniony.

STEP 1 · PICK AN INPUT
0.50
A 2×2 grid stands in for the 784-pixel image — same math, small enough to read.
STEP 2 · FORWARD PASS SNAPSHOT
STEP 3 · TELL THE MODEL THE CORRECT ANSWER
STEP 4 · BACKWARD PASS (WHAT SHOULD CHANGE)
grad_W2 (3×2)
grad_W1 (2×4)

Mini-batche i epoki

W poprzednim artykule wprowadziliśmy stochastyczny spadek gradientu (SGD): zamiast przechodzić przez wszystkie punkty danych, zbierać gradienty dla każdego i uśredniać je w jedną aktualizację, dzielimy dane na małe mini-batche i aktualizujemy wagi po każdym batchu. Rozmiar batcha to po prostu liczba przykładów, po których uśredniasz przed wykonaniem aktualizacji wag — ten sam wzór na gradient, to samo uśrednianie, tylko inna liczba przykładów.

Jak widzieliśmy w poprzednim artykule, gdzie mieliśmy 2 punkty danych na batch z 5 łącznie, wprowadza to pewien szum — gradient policzony z 2 przykładów nie wskaże dokładnie tego samego kierunku co gradient ze wszystkich 5 — ale znacznie częstsze aktualizacje z nawiązką to rekompensują.

Zobaczmy, jak wypada to przy naszych 60 000 obrazów treningowych.

Bez mini-batchy (pełnobatchowy spadek gradientu) jedno przejście przez dane wygląda tak:

  1. Przejście w przód wszystkich 60 000 obrazów przez sieć
  2. Policzenie straty uśrednionej po wszystkich 60 000 predykcjach
  3. Propagacja wsteczna, żeby dostać gradient (uśredniony po wszystkich obrazach)
  4. Jedna aktualizacja wag

To 1 aktualizacja wag po obejrzeniu każdego obrazu. Gradient jest bardzo dokładny (uwzględnia cały zbiór), ale model nie uczy się niczego, dopóki nie przetworzy wszystkich 60 000 obrazów.

Z mini-batchami rozmiaru 32 to samo przejście wygląda zupełnie inaczej:

  1. Weź obrazy 1–32, przejście w przód, policz stratę, propagacja wsteczna, aktualizuj wagi
  2. Weź obrazy 33–64, przejście w przód, policz stratę, propagacja wsteczna, aktualizuj wagi
  3. Weź obrazy 65–96, przejście w przód, policz stratę, propagacja wsteczna, aktualizuj wagi
  4. … powtórz dla wszystkich 60,000/32=1,875\lfloor 60{,}000 / 32 \rfloor = 1{,}875 mini-batchy

To 1 875 aktualizacji wag w tym samym przejściu przez dane.

Każdy pojedynczy gradient jest bardziej zaszumiony (opiera się tylko na 32 obrazach zamiast 60 000), ale model poprawia się 1 875 razy zamiast raz. Zanim zobaczy wszystkie dane, wykonał już setki korekt — uczy się po drodze, a nie czeka do końca. (Skrajny przypadek to rozmiar batcha = 1 — aktualizacja po każdym pojedynczym obrazie. To 60 000 aktualizacji na epokę, ale każdy gradient jest bardzo zaszumiony, bo opiera się na jednym przykładzie.)

Gdy model przejdzie przez każdy mini-batch — wszystkie 1 875 — zobaczył każdy obraz treningowy dokładnie raz. To pełne przejście przez cały zbiór nazywa się epoką. Zwykle trenujemy przez wiele epok — każde przejście przez dane poprawia model dalej.

Liczba batchy na epokę wynika z rozmiaru zbioru i rozmiaru batcha:

obrazoˊw/rozmiar batcha=60,000/32=1,875 batchy na epokę\lfloor \text{obrazów} / \text{rozmiar batcha} \rfloor = \lfloor 60{,}000 / 32 \rfloor = 1{,}875 \text{ batchy na epokę}

Na początku każdej epoki tasujemy dane, żeby mini-batche były za każdym razem inne — to powstrzymuje model przed uczeniem się wzorców w kolejności zamiast w samych obrazach.

Możesz się zastanawiać, jak dobrać właściwą liczbę epok. Zależy to od tego, kiedy model przestaje poprawiać się na niewidzianych danych. Za mało epok i model nie nauczył się dość; za dużo i zaczyna zapamiętywać dane treningowe zamiast generalizować. W praktyce ustawia się wysoką maksymalną liczbę epok i pozwala wczesnemu zatrzymaniu zdecydować, kiedy naprawdę przerwać — monitoruje ono trafność walidacyjną i zatrzymuje trening, gdy ta się wypłaszcza.

Jeśli puścimy trening na 5 epok, zobaczymy:

60,000/32=1,875 batchy na epokę×5 epok=9,375 aktualizacji wag łącznie\lfloor 60{,}000 / 32 \rfloor = 1{,}875 \text{ batchy na epokę} \times 5 \text{ epok} = 9{,}375 \text{ aktualizacji wag łącznie}

Oznacza to, że spadek gradientu odpala 9 375 razy — za każdym razem przetwarzając batch 32 obrazów, licząc uśredniony gradient i pchając każdą wagę w sieci.

Budowanie modelu

Zbudujmy model, który bierze 784 wejścia (piksele) i wypuszcza 10 prawdopodobieństw klas (po jednym na cyfrę). Użyjemy tej samej struktury HiddenLayer co w poprzednim artykule — macierzy wag WW, wektora biasów b\mathbf{b} i funkcji aktywacji:

output=f(Wx+b)\text{output} = f(W\mathbf{x} + \mathbf{b})

W poprzednim artykule nasz model był pojedynczym równaniem liniowym — zaledwie 2 parametry, żadnych warstw, żadnych funkcji aktywacji. Liczyliśmy gradient wprost, bez potrzeby przekazywania czegokolwiek wstecz, bo za nim nic nie było. Teraz, przy wielu warstwach, każda warstwa musi policzyć własne gradienty i przekazać sygnał gradientu warstwie przed sobą — to właśnie przejście wstecz.

Matematyka reguły łańcuchowej jest ta sama, którą wyprowadziliśmy wyżej, tylko opakowana w klasę:

class HiddenLayer:
    def __init__(self, n_inputs, n_neurons):
        # We use initialization proposed by Kaiming He et al. (2015):
        # random weights scaled by sqrt(2/n) — keeps activations balanced
        # for ReLU networks (too large → explode, too small → vanish)
        self.W = np.random.randn(n_neurons, n_inputs) * np.sqrt(2.0 / n_inputs)
        self.b = np.zeros(n_neurons)

    def forward(self, x):
        self.x = x                          # save for backward pass
        self.z = self.W @ x + self.b        # weighted sum
        self.out = np.maximum(0, self.z)    # ReLU activation
        return self.out

    def backward(self, grad_output):
        # ReLU derivative: 1 if z > 0, else 0
        grad_z = grad_output * (self.z > 0)

        # Gradients for this layer's parameters (chain rule)
        self.grad_W = np.outer(grad_z, self.x)   # ∂loss/∂W = grad_z ⊗ x
        self.grad_b = grad_z                      # ∂loss/∂b = grad_z

        # Gradient to pass to the previous layer (chain rule continues)
        return self.W.T @ grad_z                  # ∂loss/∂x = Wᵀ · grad_z

    def update(self, lr):
        # Gradient descent: update parameters
        self.W -= lr * self.grad_W
        self.b -= lr * self.grad_b

Warstwa ma trzy metody:

  1. forward: policz Wx+bW\mathbf{x} + \mathbf{b}, zastosuj ReLU
  2. backward: odbierz gradient z następnej warstwy, policz gradienty lokalne regułą łańcuchową i zwróć gradient do przekazania poprzedniej warstwie
  3. update: zastosuj spadek gradientu — odejmij lr × gradient od każdego parametru

Oddzielamy backward od update, żeby móc kumulować gradienty przez cały mini-batch przed aktualizacją — dokładnie jak robi to Keras.

grad_input na końcu to sygnał gradientu przekazywany wstecz do poprzedniej warstwy — tak reguła łańcuchowa „płynie” przez sieć, dokładnie jak na diagramie z poprzedniego artykułu:

Forward:   x ──▶ Layer 1 ──▶ Layer 2 ──▶ Output ──▶ Loss
Backward:  x ◀── Layer 1 ◀── Layer 2 ◀── Output ◀── ∂L

Każda warstwa odbiera gradient z prawej, liczy własne gradienty parametrów (żeby zaktualizować WW i b\mathbf{b}) i przekazuje pozostały gradient w lewo.

Teraz stwórzmy jeszcze warstwę wyjściową, która używa softmaxu zamiast ReLU (bo ostatnia warstwa musi produkować prawdopodobieństwa, a nie aktywacje ReLU):

class OutputLayer:
    def __init__(self, n_inputs, n_classes):
        self.W = np.random.randn(n_classes, n_inputs) * np.sqrt(2.0 / n_inputs)
        self.b = np.zeros(n_classes)

    def forward(self, x):
        self.x = x
        self.z = self.W @ x + self.b
        # Softmax instead of ReLU
        exp = np.exp(self.z - np.max(self.z))
        self.probs = exp / np.sum(exp)
        return self.probs

    def backward(self, label):
        # Combined softmax + cross-entropy gradient: p - y
        grad_z = self.probs.copy()
        grad_z[label] -= 1

        # Same gradient formulas as Layer
        self.grad_W = np.outer(grad_z, self.x)
        self.grad_b = grad_z
        return self.W.T @ grad_z

    def update(self, lr):
        self.W -= lr * self.grad_W
        self.b -= lr * self.grad_b

Teraz możemy złożyć sieć. Zacznijmy od prostej architektury — jedna warstwa ukryta ze 128 neuronami:

# 784 inputs → 128 hidden neurons → 10 output classes
layer1 = HiddenLayer(784, 128)
output = OutputLayer(128, 10)

Ale dlaczego 128? Czemu nie 1 albo 10?

Każdy neuron ukryty jest detektorem cechy. Patrzy na wszystkie 784 piksele przez własny wektor wag i wypuszcza jedną liczbę — „jak mocno mój wzorzec ujawnia się w tym obrazie?”. Jeden neuron może wystroić się na poziomą kreskę u góry (przydatne do odróżnienia 5 od 7), inny na zamkniętą pętlę w dolnej połowie (6, 8, 9), jeszcze inny na przekątną w prawym górnym rogu. Warstwa wyjściowa waży potem wszystkie te 128 sygnałów, żeby zdecydować, która cyfra jest najbardziej prawdopodobna.

Iloczyn skalarny, który liczy warstwa wyjściowa — hidden @ W₂[:, digit] — to dokładnie dopasowanie w stylu podobieństwa kosinusowego między dwoma wektorami: wektorem aktywacji cech, który warstwa ukryta wyprodukowała dla tego obrazu, i wektorem wag danej cyfry (kolumną W₂), kodującym „które cechy mają znaczenie dla tej cyfry i jak bardzo”. Wynik cyfry jest wysoki, gdy jej szablon dobrze pokrywa się z cechami, które obraz faktycznie wyzwolił — ta sama idea, co ocenianie, czy dwie strzałki wskazują w podobne strony. Wygrywa klasa o najwyższym wyniku, tak jak najbliższy sąsiad w wyszukiwaniu opartym na podobieństwie kosinusowym.

Przy tylko 1 neuronie ukrytym każdy obraz zostałby skompresowany do jednego skalara, zanim warstwa wyjściowa go zobaczy. Te 10 wyników klas byłoby wtedy 10 funkcjami liniowymi tej jednej liczby — geometrycznie musiałbyś rozstawić wszystkie 10 cyfr wzdłuż jednej prostej. Nie istnieje takie uporządkowanie na prostej 1-D, w którym obszar każdej cyfry byłby oddzielony od pozostałych dziewięciu, więc sieć nie potrafi przedstawić rozdziału na 10 klas przez jednowymiarowe wąskie gardło, choćbyś trenował ją nie wiadomo jak długo. Szerokość warstwy ukrytej wyznacza wymiarowość, na której pracuje warstwa wyjściowa, a 10 klas potrzebuje więcej niż jednego wymiaru.

Samo 128 to wygodna wartość domyślna — dość duża, by dobrze dopasować MNIST (97–98% przy jednej warstwie), dość mała, by trenować się w sekundy na CPU. Użyj 64, a stracisz około pół procenta; użyj 512, a zyskasz odrobinę, trenując wolniej i mocniej się przeuczając. Będziemy to zmieniać w następnym artykule i zobaczymy, co się stanie.

To 784×128+128=100,480784 \times 128 + 128 = 100{,}480 parametrów w pierwszej warstwie i 128×10+10=1,290128 \times 10 + 10 = 1{,}290 w warstwie wyjściowej — łącznie 101 770 parametrów. W porównaniu z 2 parametrami (ww i bb) w przykładzie dopasowania prostej z poprzedniego artykułu to 50 000 razy więcej. A jednak algorytm treningowy jest identyczny.

Ta sama sieć w Kerasie

Keras nazywa swoje warstwy Dense — skrót od „densely connected” („gęsto połączona”, czyli „w pełni połączona”), co znaczy, że każde wejście łączy się z każdym neuronem. Warstwa Dense wykonuje dokładnie to samo obliczenie co nasze klasy NumPy powyżej: f(Wx+b)f(W\mathbf{x} + \mathbf{b}) — pomnóż wejścia przez wagi, dodaj bias, zastosuj funkcję aktywacji. Różnica polega na tym, że Keras sam załatwia inicjalizację wag, przejście w przód, liczenie gradientów i aktualizację parametrów.

Nasz HiddenLayer(784, 128) z ReLU staje się Dense(128, activation="relu"), a OutputLayer(128, 10) z softmaxem staje się Dense(10, activation="softmax"). Zauważ, że w Kerasie podajesz tylko liczbę wyjść — model potrzebuje keras.Input(shape=(784,)) na górze, żeby znać rozmiar wejścia, ale potem Keras sam wywnioskuje wejście każdej warstwy z wyjścia poprzedniej. Więc Dense(10) wie, że ma 128 wejść, bo warstwa przed nim wypuszcza 128.

Warstwa Dense, w pełni połączona, FFNN, MLP — jaka jest różnica?

Te terminy bywają używane zamiennie, ale opisują różne rzeczy:

  • Warstwa Dense / w pełni połączona — okablowanie jednej warstwy: każde wejście łączy się z każdym neuronem, operacja f(Wx+b)f(W\mathbf{x} + \mathbf{b}), którą budujemy. To klocek.
  • MLP (perceptron wielowarstwowy) — cała sieć złożona z ułożonych warstw gęstych z nieliniowościami między nimi. Model, który właśnie zbudowaliśmy, jest MLP: 784 → 128 (ReLU) → 10 (softmax).
  • FFNN (sieć neuronowa jednokierunkowa) — opisuje topologię: dane płyną w jedną stronę, wejście → wyjście, bez pętli i bez pamięci poprzednich wejść. Nasza sieć też jest jednokierunkowa.

Dla sieci takiej jak nasza pasują więc wszystkie trzy etykiety — to w pełni połączony MLP i jest jednokierunkowa. Jedna subtelność: „jednokierunkowa” jest szersza niż „gęsta”. Konwolucyjna sieć neuronowa (CNN) też jest jednokierunkowa (bez pętli), ale jej warstwy są konwolucyjne, nie gęste — więc nie każda FFNN jest zbudowana z warstw gęstych. A rekurencyjna sieć neuronowa (RNN) nie jest jednokierunkowa, bo zawraca własne wyjście na wejście, by nieść pamięć przez sekwencję. Ten kontrast — jednokierunkowa vs rekurencyjna — tłumaczy, dlaczego te nazwy pojawiają się jako odrębne architektury, gdy porównuje się modele do zadań takich jak język, gdzie kolejność słów ma znaczenie.

Sequential układa je w model, w którym wyjście każdej warstwy zasila następną:

model = keras.Sequential([
    # 784 inputs (28×28 image pixels)
    keras.Input(shape=(784,)),
    # 128 neurons/outputs, ReLU activation
    keras.layers.Dense(128, activation="relu"),
    # 10 neurons/outputs (one per digit), softmax activation
    keras.layers.Dense(10, activation="softmax"),
])

model.summary()
# Total params: 101,770 — same number we counted by hand

Budowanie pętli treningowej

Pętla treningowa to ten sam 4-krokowy proces z poprzedniego artykułu — przejście w przód, strata, propagacja wsteczna, spadek gradientu. Dla każdego mini-batcha uruchamiamy przejście w przód i propagację wsteczną na każdym obrazie, żeby skumulować gradienty, potem je uśredniamy i raz aktualizujemy wagi. Odpowiada to temu, co Keras robi wewnętrznie:

def train(layers, output_layer, X_train, y_train, X_val, y_val,
          epochs=5, lr=0.1, batch_size=32):
    n = X_train.shape[0]
    history = {"train_loss": [], "train_acc": [], "val_acc": []}

    for epoch in range(epochs):
        # Shuffle training data at the start of each epoch
        indices = np.random.permutation(n)
        X_shuffled = X_train[indices]
        y_shuffled = y_train[indices]

        epoch_loss = 0.0
        correct = 0

        all_layers = layers + [output_layer]

        for i in range(0, n, batch_size):
            X_batch = X_shuffled[i:i+batch_size]
            y_batch = y_shuffled[i:i+batch_size]
            bs = len(X_batch)

            # Accumulate gradients over the mini-batch
            accumulated = {id(l): (np.zeros_like(l.W), np.zeros_like(l.b))
                           for l in all_layers}

            for x, label in zip(X_batch, y_batch):
                # 1. Forward pass
                h = x
                for layer in layers:
                    h = layer.forward(h)
                probs = output_layer.forward(h)

                # 2. Loss computation
                loss = -np.log(probs[label] + 1e-10)
                epoch_loss += loss
                correct += (np.argmax(probs) == label)

                # 3. Backpropagation (compute gradients, don't update yet)
                grad = output_layer.backward(label)
                for layer in reversed(layers):
                    grad = layer.backward(grad)

                # Accumulate gradients
                for l in all_layers:
                    accumulated[id(l)][0][:] += l.grad_W
                    accumulated[id(l)][1][:] += l.grad_b

            # 4. Gradient descent: average gradients and update
            for l in all_layers:
                l.grad_W = accumulated[id(l)][0] / bs
                l.grad_b = accumulated[id(l)][1] / bs
                l.update(lr)

        # Track metrics
        train_loss = epoch_loss / n
        train_acc = correct / n
        val_acc = evaluate(layers, output_layer, X_val, y_val)
        history["train_loss"].append(train_loss)
        history["train_acc"].append(train_acc)
        history["val_acc"].append(val_acc)

        print(f"Epoch {epoch+1}/{epochs} — loss: {train_loss:.4f}, "
              f"train acc: {train_acc:.2%}, val acc: {val_acc:.2%}")

    return history


def evaluate(layers, output_layer, X, y):
    correct = 0
    for x, label in zip(X, y):
        h = x
        for layer in layers:
            h = layer.forward(h)
        probs = output_layer.forward(h)
        correct += (np.argmax(probs) == label)
    return correct / len(y)

To ta sama pętla SGD z poprzedniego artykułu: przetasuj dane, podziel na mini-batche i dla każdego przykładu uruchom przejście w przód, policz stratę, przepuść gradienty wstecz i zaktualizuj wagi. Jedyna różnica polega na tym, że zamiast 5 punktów danych i 2 parametrów mamy 60 000 obrazów i 101 770 parametrów.

Oto to samo w Kerasie — cała powyższa pętla treningowa zwija się do trzech linijek:

model.compile(
    optimizer=keras.optimizers.SGD(learning_rate=0.1),  # gradient descent with lr=0.1
    loss="sparse_categorical_crossentropy",              # cross-entropy loss
    metrics=["accuracy"],
)

history = model.fit(
    X_train, y_train,
    epochs=5,
    batch_size=32,
    validation_split=0.2,  # hold out 20% of training data for validation
)

compile ustawia funkcję straty i optymalizator, a fit uruchamia pełną pętlę — przejście w przód, stratę, propagację wsteczną i spadek gradientu dla każdego mini-batcha w każdej epoce.

Rozłóżmy argumenty:

  • "sparse_categorical_crossentropy" — to ta entropia krzyżowa, którą wyprowadziliśmy wyżej. Nazwa ma trzy części: „sparse” znaczy, że podajemy etykiety jako liczby całkowite (np. 3), a nie wektory one-hot ([0,0,0,1,0,0,0,0,0,0]); „categorical” znaczy, że klasyfikujemy do wielu kategorii; „crossentropy” to funkcja straty log(pcorrect)-\log(p_{\text{correct}}). Keras ma też "categorical_crossentropy" (bez „sparse”) na wypadek, gdy etykiety są już zakodowane one-hot — matematyka jest ta sama, tylko format wejścia inny.
  • SGD(learning_rate=0.1) — stochastyczny spadek gradientu, ta sama reguła aktualizacji w = w - lr * dw z poprzedniego artykułu. Keras oferuje bardziej zaawansowane optymalizatory (Adam, RMSprop itd.), które automatycznie dostosowują współczynnik uczenia, ale zwykły SGD to to, czego używaliśmy, i tutaj sprawdza się dobrze.
  • validation_split=0.2 — odkłada 20% danych treningowych (12 000 obrazów) jako zbiór walidacyjny. Po każdej epoce Keras ocenia model na tych odłożonych obrazach (tylko przejście w przód, bez aktualizacji wag), żebyśmy mogli śledzić, jak dobrze generalizuje. Pozostałe 48 000 obrazów służy do właściwego treningu.

Przygotowanie danych

Zanim zaczniemy trenować sieć, musimy przygotować surowe dane pikselowe — sieć nie umie pracować bezpośrednio z siatkami liczb całkowitych 28×28. Zrobimy dwie rzeczy:

  1. Spłaszczenie — przekształcenie siatki 28×28 w pojedynczy wektor 784 liczb
  2. Normalizacja — przeskalowanie wartości pikseli z [0,255][0, 255] do [0,1][0, 1]

Spłaszczenie jest potrzebne, bo nasza sieć używa warstw gęstych (w pełni połączonych), w których każde wejście łączy się z każdym neuronem — oczekują one płaskiej listy liczb, a nie siatki 2D. Inne architektury, jak konwolucyjne sieci neuronowe (CNN), potrafią pracować bezpośrednio z kształtem 2D, ale my na razie zostaniemy przy warstwach gęstych. Spłaszczenie przekształca siatkę 28×28 w pojedynczy wektor 784 liczb — nasza sieć używa warstw gęstych (w pełni połączonych), gdzie każde wejście łączy się z każdym neuronem, więc oczekują płaskiej listy liczb, a nie siatki 2D. Inne architektury, jak CNN, potrafią pracować bezpośrednio z kształtem 2D, ale my na razie zostaniemy przy warstwach gęstych.

flatImage = image.reshape(784)  # [0, 0, 0, ..., 156, 252, 128, ..., 0, 0]  — 784 values

Normalizacja pomaga utrzymać wartości wejściowe w tej samej skali co wagi. Wagi są zwykle inicjalizowane małymi losowymi liczbami wokół 0, więc jeśli wejścia sięgają 255, sumy ważone robią się ogromne, co prowadzi do wielkich aktywacji, wielkich gradientów i niestabilnego treningu. Dzielenie przez 255 wrzuca wszystko do [0,1][0, 1] — zakresu, w którym małe losowe wagi od początku dają sensowne wyjścia. To standardowa praktyka w uczeniu maszynowym, nie specyfika MNIST.

Bez normalizacji neurony mogą saturować — funkcja aktywacji utyka w płaskim obszarze, gdzie jej pochodna wynosi ~0, gradient zanika, a sieć przestaje się uczyć. To ten sam mechanizm, który stoi za problemem zanikającego gradientu. Szczegółową interaktywną demonstrację tego, jak nieznormalizowane wejścia wywołują saturację, znajdziesz w artykule o umierającym ReLU.

Normalizacja przeskalowuje wartości pikseli z [0,255][0, 255] do [0,1][0, 1]:

# Normalize pixel values from [0, 255] to [0, 1]
normalizedImage = flatImage / 255.0  # [0.0, 0.0, 0.0, ..., 0.61, 0.99, 0.50, ..., 0.0, 0.0]

Zbiór treningowy, walidacyjny i testowy

Zanim uruchomimy trening, jest ważne rozróżnienie do zrozumienia. Mamy 60 000 obrazów treningowych i 10 000 testowych — ale tak naprawdę potrzebujemy trzech zbiorów, nie dwóch:

  • Zbiór treningowy — dane, z których model się uczy. W każdej epoce model przetwarza każdy obraz z tego zbioru, liczy gradienty i aktualizuje wagi. Te obrazy model widzi w kółko.
  • Zbiór walidacyjny — po każdej epoce model wykonuje przejście w przód na tych obrazach, żeby policzyć stratę i trafność, ale ta strata służy wyłącznie do raportowania — żadnej propagacji wstecznej, żadnego liczenia gradientów, żadnych aktualizacji wag. To czysty pomiar. Mówi nam, jak dobrze model generalizuje: jeśli osiąga 98% na danych treningowych, ale tylko 90% na walidacyjnych, to zapamiętuje obrazy treningowe zamiast uczyć się ogólnych wzorców. Jeśli trafność walidacyjna przestaje rosnąć, a treningowa dalej się wspina, model się przeucza.
  • Zbiór testowy — całkowicie oddzielny zbiór, oceniany raz, na samym końcu, gdy wszystkie decyzje treningowe są już ostateczne. To prawdziwa miara tego, jak dobrze model generalizuje na obrazy, których nigdy nie widział.

W kategoriach naszej 4-krokowej pętli treningowej:

Zbiór treningowyZbiór walidacyjnyZbiór testowy
1. Przejście w przódtaktaktak
2. Policzenie stratytaktak (tylko do raportowania)tak (tylko do raportowania)
3. Propagacja wstecznataknienie
4. Aktualizacja wagtaknienie

Dla obrazów treningowych wykonują się wszystkie 4 kroki — model się uczy. Dla walidacyjnych i testowych działają tylko kroki 1 i 2 — model mierzy, jak mu idzie, ale nie zmienia wag na podstawie tego, co widzi.

Czemu nie użyć zbioru testowego do jednego i drugiego? Bo za każdym razem, gdy sprawdzasz wynik na jakimś zbiorze i podejmujesz na tej podstawie decyzję (np. „czy trenować dalej?”), subtelnie dopasowujesz się do tych danych. Jeśli użyjesz zbioru testowego do decyzji, kiedy przerwać trening, trafność testowa stanie się optymistycznie obciążona. Zbiór walidacyjny przejmuje to obciążenie na siebie, dzięki czemu testowy pozostaje uczciwy.

Zbiór walidacyjny służy też do strojenia hiperparametrów — wyborów takich jak współczynnik uczenia, rozmiar batcha, liczba warstw i liczba epok. Próbujesz różnych ustawień, porównujesz trafność walidacyjną i wybierasz najlepsze. Zbiór testowy pozostaje przez cały ten proces zapieczętowany, żeby mógł dać nieobciążoną końcową ocenę.

W praktyce zbiór walidacyjny możesz utworzyć, odcinając kawałek danych treningowych. Keras ułatwia to przez validation_split:

history = model.fit(
    X_train, y_train,
    epochs=5,
    batch_size=32,
    validation_split=0.2,  # use 20% of training data as validation
)

Możesz też podzielić ręcznie i przekazać osobny zbiór przez validation_data=(X_val, y_val). Użyjemy validation_split=0.2 — Keras odłoży 20% danych treningowych (12 000 obrazów) na walidację, trenując na pozostałych 48 000. Te 10 000 obrazów testowych pozostaje całkowicie osobno do końcowej oceny.

Trenowanie modelu

Teraz jesteśmy wreszcie gotowi uruchomić trening. Najpierw wczytajmy i przygotujmy zbiór — MNIST jest tak standardowy, że Keras ma go wbudowanego:

import keras
import numpy as np

(train_images, train_labels), (test_images, test_labels) = keras.datasets.mnist.load_data()

# Flatten 28×28 → 784 and normalize to [0, 1]
X_train = train_images.reshape(-1, 784).astype("float32") / 255.0
X_test = test_images.reshape(-1, 784).astype("float32") / 255.0
y_train = train_labels
y_test = test_labels

print(f"Training: {X_train.shape[0]} images, {X_train.shape[1]} pixels each")
print(f"Test:     {X_test.shape[0]} images")
# Training: 60000 images, 784 pixels each
# Test:     10000 images

Mamy teraz 60 000 obrazów treningowych (każdy to wektor 784 wartości między 0 a 1) i ich etykiety (każda 0–9). Zbiór testowy jest osobno — użyjemy go tylko do sprawdzenia, jak dobrze model generalizuje na cyfry, których nigdy nie widział podczas treningu.

Wytrenujmy naszą sieć:

# Split: 80% train, 20% validation (same as Keras validation_split=0.2)
n_val = int(0.2 * len(X_train))
X_val, y_val = X_train[:n_val], y_train[:n_val]
X_train_sub, y_train_sub = X_train[n_val:], y_train[n_val:]

layer1 = HiddenLayer(784, 128)
output = OutputLayer(128, 10)

history = train([layer1], output, X_train_sub, y_train_sub, X_val, y_val,
                epochs=5, lr=0.1, batch_size=32)

# Epoch 1/5 — loss: 0.3260, train acc: 90.70%, val acc: 94.58%
# Epoch 2/5 — loss: 0.1608, train acc: 95.30%, val acc: 95.97%
# Epoch 3/5 — loss: 0.1147, train acc: 96.64%, val acc: 96.49%
# Epoch 4/5 — loss: 0.0898, train acc: 97.45%, val acc: 96.94%
# Epoch 5/5 — loss: 0.0737, train acc: 97.87%, val acc: 97.27%

Oto jak wygląda trening przez 5 epok — lewy wykres pokazuje spadającą stratę w miarę uczenia się modelu, prawy pokazuje rosnącą trafność. Zielone kropki to trafność walidacyjna (val_accuracy), mierzona na koniec każdej epoki na odłożonych obrazach, na których model nigdy nie trenuje:

~97% trafności walidacyjnej po 5 epokach — sieć poprawnie klasyfikuje niemal wszystkie odłożone obrazy, na których nigdy nie trenowała. Nieźle jak na prostą dwuwarstwową sieć i zaledwie 5 epok treningu.

Możesz zauważyć, że już epoka 1 pokazuje ~90% trafności. To dlatego, że trafność jest mierzona na końcu epoki — do tego momentu model przetworzył już wszystkie 1 500 batchy (48 000 obrazów) i wykonał 1 500 aktualizacji wag. Gdybyśmy śledzili trafność per batch, zobaczyłbyś, że startuje w okolicach 10% (losowe zgadywanie dla 10 cyfr) i szybko rośnie w trakcie pierwszej epoki.

Jest jeszcze jeden ciekawy szczegół wart zauważenia. Przyjrzyj się uważnie odstępowi między linią niebieską (trening) a zieloną (walidacja) — zwłaszcza na wykresie straty. Strata treningowa wciąż stabilnie spada, ale strata walidacyjna wypłaszcza się około epoki 4–5, a nawet zaczyna pełznąć w górę. To początek przeuczenia: model zaczyna zapamiętywać dane treningowe zamiast uczyć się wzorców, które generalizują na nowe obrazy. Po 5 epokach odstęp jest niewielki, ale gdybyśmy trenowali przez 20+ epok, trafność treningowa zbliżałaby się do 100%, podczas gdy walidacyjna stanęłaby albo spadła. Właśnie dlatego potrzebujemy zbioru walidacyjnego — mówi nam, kiedy przestać. Przeuczenie zbadamy dogłębnie w następnym artykule.

Wypróbuj sam

Mamy towarzyszący notatnik, w którym możesz uruchomić implementacje w Kerasie i NumPy obok siebie. Wszystkie komórki wykonują się automatycznie — zobaczysz najpierw wynik treningu w Kerasie, potem wynik treningu w NumPy z tymi samymi metrykami.

Wyjście Kerasa wygląda tak:

Wyjście treningu w Kerasie pokazujące postęp epok, accuracy, loss, val_accuracy i val_loss

Oto co znaczy każdy element:

  • 1500/1500 — ukończono 1 500 mini-batchy z 1 500 łącznie (48 000 obrazów treningowych / 32 na batch). W trakcie treningu zobaczysz, jak ten licznik rośnie (np. 18/1500 znaczy, że zrobiono 18 batchy).
  • 5s 4ms/step — epoka zajęła 5 sekund, ~4 milisekundy na batch
  • accuracy: 0.9529 i loss: 0.1631 — metryki treningowe, uśrednione po wszystkich batchach w epoce
  • val_accuracy: 0.9585 i val_loss: 0.1434 — metryki walidacyjne, policzone raz na koniec epoki na odłożonych 12 000 obrazów

Liczby z Kerasa i z NumPy nie będą identyczne (inna losowa inicjalizacja wag), ale powinny być bliskie — obie w okolicach ~97% trafności walidacyjnej po 5 epokach. Potwierdza to, że nasz kod NumPy pisany od zera implementuje ten sam algorytm co Keras.

Co omówiliśmy

Wzięliśmy każde pojęcie z poprzedniego artykułu i zastosowaliśmy je do prawdziwego problemu — klasyfikacji odręcznych cyfr przy ponad 100 000 parametrów zamiast dopasowywania prostej przy dwóch:

  1. Klasyfikacja vs regresja: softmax zamienia surowe logity w prawdopodobieństwa, entropia krzyżowa mierzy, jak bardzo te prawdopodobieństwa są chybione
  2. Przejście w przód: wejście płynie przez warstwy, każda liczy f(Wx+b)f(W\mathbf{x} + \mathbf{b}) — ten sam wzór, teraz w postaci macierzowej
  3. Propagacja wsteczna: gradient softmaxu + entropii krzyżowej upraszcza się do py\mathbf{p} - \mathbf{y}, a reguła łańcuchowa płynie wstecz przez warstwy dokładnie tak, jak wyprowadziliśmy ręcznie
  4. Pętla treningowa: przetasuj, podziel na mini-batche, przejście w przód → strata → propagacja wsteczna → aktualizacja — powtarzane dla każdego batcha w każdej epoce

Algorytm jest identyczny z tym, który zbudowaliśmy w poprzednim artykule. Keras go automatyzuje, ale pod maską to to samo obliczenie, które nasz kod NumPy wykonuje krok po kroku.

Ale przez cały czas używaliśmy ustawień domyślnych — jedna warstwa ukryta ze 128 neuronami, współczynnik uczenia 0.1, rozmiar batcha 32. Co się stanie, jeśli je zmienimy? W następnym artykule systematycznie pozmieniamy każdy element — współczynnik uczenia, rozmiar batcha, głębokość sieci, funkcje aktywacji — i zbadamy przeuczenie, wczesne zatrzymanie oraz to, jak rozpoznać, że trening idzie źle.