W poprzednich artykułach wytrenowaliśmy sieć neuronową na MNIST przy użyciu warstw gęstych — w których każdy neuron jednej warstwy łączy się z każdym neuronem następnej, a wagi są aktualizowane przez propagację wsteczną. Żeby pracować z warstwami gęstymi, musieliśmy spłaszczyć każdy obraz 28×28 do wektora 784 elementów i podać go do warstwy 128 neuronów. Sama pierwsza warstwa miała 784×128=100,352784 \times 128 = 100{,}352 wag + 128128 biasów = 100,480100{,}480 parametrów — każdy piksel wejściowy łączy się z każdym neuronem. Działało to — osiągnęliśmy 97% trafności — ale takie podejście ma dwa fundamentalne problemy:

1. Brak wbudowanej struktury przestrzennej. Spłaszczenie zachowuje każdy piksel i jego kolejność; wektor można przekształcić z powrotem w obraz. Warstwa gęsta nie uwzględnia jednak sąsiedztwa pikseli ani nie współdzieli detektora między pozycjami. Przesunięty wzorzec trafia do innych wag, więc nauczenie się go w jednym miejscu nie zapewnia rozpoznawania w pozostałych.

2. Zbyt wiele parametrów. Przy obrazie 28×28 sto tysięcy parametrów jest do ogarnięcia. Ale prawdziwe obrazy są znacznie większe. Obraz RGB 224×224 (typowy rozmiar wejścia) ma 224×224224 \times 224 pikseli × 3\times\ 3 kanały kolorów =150,528= 150{,}528 wartości. Jedna warstwa gęsta z 512 neuronami potrzebowałaby 150,528×512≈77150{,}528 \times 512 \approx 77 milionów parametrów — i to na jedną warstwę. To drogie w trenowaniu i łatwo przeuczalne.

Jak liczba parametrów wpływa na przeuczenie?
Więcej parametrów zwiększa zdolność modelu do dopasowania danych treningowych, także szumu. Generalizacja zależy również od danych, regularyzacji, architektury i optymalizacji. Sama przewaga liczby parametrów nad liczbą przykładów nie oznacza przeuczenia.

Konwolucyjne sieci neuronowe (CNN) rozwiązują oba problemy naraz. Zamiast patrzeć na wszystkie piksele naraz, CNN skupia się na małych lokalnych obszarach — na przykład na fragmencie 3×3 — i uczy się rozpoznawać wzorce wewnątrz nich. Fundamentalna różnica: warstwy gęste uczą się wzorców globalnych, obejmujących wszystkie piksele naraz, podczas gdy warstwy konwolucyjne uczą się wzorców lokalnych — i wykorzystują ten sam detektor w każdej pozycji obrazu.

Rozważmy rozróżnianie kwadratów i okręgów na siatce 28×28. Lokalne fragmenty zawierają wskazówki: kwadrat ma ostre narożniki i proste boki, a okrąg zakrzywione krawędzie. Diagram pokazuje przykładowe cechy; trening decyduje, które odpowiedzi model faktycznie wykorzysta.

28 × 28

Użycie CNN pozwala zaadresować każdy z problemów wprost:

Konwolucja zapewnia lokalne połączenia i współdzielenie wag. Filtr 3×3 dla jednego kanału wejściowego ma 9 wag i bias. Te same wagi są stosowane w każdej pozycji, więc warstwa może reagować na wyuczony wzorzec w różnych miejscach.

CNN stosuje się do klasyfikacji obrazów, wykrywania obiektów i rozpoznawania tekstu. Operację przesuwania filtra można też zastosować do sygnałów jednowymiarowych, takich jak dźwięk, oraz danych trójwymiarowych, takich jak obrazy objętościowe.

Konwolucja w sieciach neuronowych

Nasza sieć łączy konwolucję, aktywację ReLU, pooling i klasyfikator gęsty. Konwolucja oblicza lokalne odpowiedzi, ReLU wprowadza nieliniowość, a pooling zmniejsza wymiary przestrzenne. Tak wygląda architektura w notacji Keras:

model = keras.Sequential([
    Input(...),              # input image
    Conv2D(...),             # convolution
    MaxPooling2D(...),       # pooling
    Conv2D(...),             # convolution
    MaxPooling2D(...),       # pooling
    Flatten(...),            # flatten to 1D
    Dense(...),              # classification
])

Konwolucja wykrywa wzorce, pooling kompresuje wynik, a warstwa gęsta dokonuje końcowej klasyfikacji. Oto jak wygląda kompletny potok CNN — od pikseli do predykcji:

28×28×1
Input
28×28 grayscale
→
26×26×2
Conv2D
2 filters, 3×3, ReLU
→
13×13×2
MaxPool
2×2, stride 2
→
338
Flatten
13×13×2 = 338
→
square 56%
circle 44%
Dense
1 neuron, sigmoid
Parameters: Conv: 2×(3×3×1+1) = 20  |  Dense: 338×1+1 = 339  |  Total: 359

Warstwy konwolucyjne i pooling tworzą cechy, które warstwa gęsta łączy w predykcję. Trening dostosowuje jednocześnie ekstraktor cech i klasyfikator.

Krok konwolucji wykrywa wzorce lokalne — przesuwa wyuczone filtry po obrazie i produkuje mapy cech, które podświetlają, gdzie pojawia się każdy wzorzec. Niczego nie klasyfikuje — przekształca surową siatkę pikseli w bogatszą reprezentację. To ekstrakcja cech, ta sama idea co inżynieria cech w klasycznym ML. Zamiast podawać surowe dane do klasyfikatora, najpierw przekształcasz je w bardziej użyteczne reprezentacje. Różnica polega na tym, że CNN uczy się automatycznie, które cechy wyodrębniać — wartości jąder są odkrywane w trakcie treningu, a nie projektowane ręcznie.

Krok poolingu kompresuje przestrzennie każdą mapę cech — zmniejsza szerokość i wysokość, zachowując najsilniejsze sygnały. Redukuje to liczbę wartości, które musi przetworzyć warstwa gęsta (z 28×28×2 = 1568 do 14×14×2 = 392 w naszym modelu) i czyni cechy odporniejszymi na drobne przesunięcia pozycji.

Zaimplementujemy te warstwy w NumPy i obejrzymy filtry wyuczone w prostym zadaniu klasyfikacji kształtów.

Konwolucja jako przekształcenie obrazu

Konwolucja nie jest wynalazkiem ML — to fundamentalna technika z przetwarzania sygnałów i obrazów, istniejąca na długo przed uczeniem głębokim. Jeśli kiedykolwiek zastosowałeś filtr rozmycia, wyostrzenia albo detekcji krawędzi w programie graficznym — to była konwolucja. Pod maską każdy filtr jest małą siatką liczb (zwaną jądrem), która przesuwa się po obrazie. W każdej pozycji każda wartość jądra zostaje pomnożona przez nakładający się piksel, iloczyny są sumowane, a wynik zapisywany na wyjściu.

Przełączaj filtry poniżej i porównuj wartości filtra 3×3 z wyjściem po prawej:

Input (32×32)
Kernel (3×3)
Output (30×30)

Zwróć uwagę, jak detektor krawędzi poziomych podświetla górną i dolną krawędź kształtu — bierze się to z tego, że górny wiersz jądra ma wartości ujemne, a dolny dodatnie, więc reaguje tam, gdzie jasność zmienia się pionowo. Filtr wyostrzający ma dużą dodatnią wartość w środku i ujemnych sąsiadów — wzmacnia różnicę między pikselem a jego otoczeniem, czyniąc krawędzie ostrzejszymi.

To klasyczne, ugruntowane jądra z przetwarzania obrazów — zaprojektowane ręcznie dekady przed powstaniem uczenia głębokiego. Opis poniżej wyjaśnia, co liczy każde jądro i dlaczego wyjście wygląda tak, jak wygląda.

FiltrJak działa
RozmycieUśrednia wszystkie 9 pikseli po równo — wygładza różnice. Jednolity obszar zostaje taki sam, ale ostre przejścia się rozmywają
WyostrzenieWzmacnia piksel centralny względem sąsiadów. W gładkich obszarach sąsiedzi się znoszą; na krawędziach niedopasowanie zostaje wyolbrzymione
Krawędzie poziomeOdejmuje górne piksele od dolnych — jeśli są podobne, wynik jest bliski zeru. Duże wyjście tylko tam, gdzie jasność zmienia się pionowo, czyli na krawędzi poziomej
Krawędzie pionoweTa sama idea obrócona: odejmuje lewe od prawych. Wyjście jest silne tylko tam, gdzie jasność zmienia się poziomo, czyli na krawędzi pionowej

Ta sama operacja przesuwania, to samo wejście — różne wyniki. Dziewięć liczb w jądrze całkowicie decyduje o tym, jak będzie wyglądać wyjście. Świetne wizualne wyjaśnienie działania konwolucji znajdziesz w tym filmie 3Blue1Brown.

Konwolucja jako dopasowywanie wzorca

Przesuwane okno można też wykorzystać do dopasowywania wzorców. Wybierz mały fragment obrazu i porównaj go z fragmentami w innych miejscach. Następna demonstracja normalizuje te porównania, by skupić się na wzorcu, a nie jego ogólnej jasności.

Kliknij dowolną część kształtu poniżej, żeby wyciąć fragment 3×3 i użyć go jako jądra. Na przykład kliknij pionową krawędź kwadratu, żeby zobaczyć, jak podświetla wszystkie krawędzie pionowe, albo kliknij zaokrąglenie koła, żeby zobaczyć, gdzie pojawiają się podobne łuki. Widget przesuwa jądro po całym obrazie, a wyjście zapala się tam, gdzie znaleziono podobne wzorce. Możesz też kliknąć Play, żeby krok po kroku prześledzić mechanikę przesuwania:

Input (32×32)
Click to pick a 3×3 kernel
Kernel (3×3)
Cosine similarity (30×30)

Ta demonstracja używa podobieństwa cosinusowego: iloczynu skalarnego podzielonego przez długości wektorów filtra i fragmentu obrazu. Dla wektora zerowego przyjmuje wynik zero, a na wizualizacji wygasza wyniki poniżej 0,85. Konwolucja w CNN nie stosuje tej normalizacji: oblicza sumę ważoną z biasem. Demonstracja pokazuje wyszukiwanie przesuwanym fragmentem, lecz jej wyniki nie są wartościami konwolucji.

Od filtrów projektowanych ręcznie do uczonych

Oba zastosowania konwolucji — przekształcanie obrazu i dopasowywanie wzorca — zależą od dobrego doboru wartości jądra. Przetwarzanie obrazów przez dekady korzystało z jąder projektowanych ręcznie (rozmycie, wyostrzenie i filtry Sobela z dema powyżej). Są użyteczne, ale dobieranie ich ręcznie jest żmudne i ograniczone.

Kluczowa idea CNN brzmi: zamiast projektować te wartości ręcznie, pozwólmy sieci nauczyć się ich automatycznie pod dane zadanie. Filtr startuje z losowymi liczbami i jest aktualizowany metodą spadku gradientu podczas treningu — tak samo jak uczą się wagi w warstwach gęstych. Sieć sama odkrywa, które wzorce mają znaczenie dla zadania. Dla naszego zadania kwadrat vs koło może nauczyć się filtrów reagujących inaczej na proste krawędzie, a inaczej na krzywe — czegokolwiek, co pomaga rozdzielić obie klasy.

Operacja konwolucji

Zobaczyliśmy już, jak konwolucje pomagają w przekształcaniu obrazu i dopasowywaniu wzorca. Teraz przyjrzyjmy się właściwej mechanice: co dokładnie dzieje się w każdej pozycji i jak budowane jest wyjście — zwane mapą cech?

Dlaczego „mapa cech”?

Nazwa używa słowa „cecha” w tym samym sensie co ML w ogóle — mierzalna własność użyteczna dla zadania. Tak jak model tabelaryczny mógłby używać cech w rodzaju „wiek” czy „dochód”, cechą w CNN jest wzorzec wizualny w rodzaju „krawędź pionowa” albo „łuk”. Mapa cech to przestrzenna siatka pokazująca, gdzie w obrazie ta cecha została wykryta — wysokie wartości tam, gdzie wzorzec jest obecny, niskie tam, gdzie go nie ma.

Podstawowa operacja jest prosta: weź małą macierz (jądro), przesuwaj ją po wejściu i w każdej pozycji policz iloczyn skalarny — pomnóż każdą wartość jądra przez nakładający się piksel, zsumuj iloczyny — i zapisz wynik do mapy cech. Jądro może mieć dowolny rozmiar (1×1, 5×5, 7×7) — starsze sieci jak AlexNet używały jąder 11×11 — ale 3×3 to najczęstszy wybór we współczesnych architekturach i to jego będziemy używać w całym artykule.

Zobaczmy to w praktyce. Powiedzmy, że klikniesz lewą krawędź kwadratu w demie powyżej — jądrem staje się fragment 3×3 z czernią (0) jako tłem i bielą (255) tam, gdzie biegnie linia krawędzi. Gdy to jądro przesunie się na inną lewą krawędź, fragment obrazu wygląda tak samo — każda pozycja pasuje, więc iloczyny są duże:

Jądro lewej krawędzi × fragment lewej krawędzi — silne dopasowanie
kernelimage regionproducts
0
0
255
×
0
0
255
=
0×0+0×0+255×255
= 65,025
0
0
255
×
0
0
255
=
0×0+0×0+255×255
= 65,025
0
0
255
×
0
0
255
=
0×0+0×0+255×255
= 65,025
Total: 195,075

Teraz zastosujmy to samo jądro do górnej krawędzi. Białe piksele są w innych miejscach — biała kolumna jądra nakłada się głównie na czarne piksele:

Jądro lewej krawędzi × fragment górnej krawędzi — słabe dopasowanie
kernelimage regionproducts
0
0
255
×
255
255
255
=
0×255+0×255+255×255
= 65,025
0
0
255
×
0
0
0
=
0×0+0×0+255×0
= 0
0
0
255
×
0
0
0
=
0×0+0×0+255×0
= 0
Total: 65,025

Duży iloczyn skalarny może wynikać ze zgodności wartości, ich dużej skali lub obu tych czynników. Nie jest znormalizowaną miarą podobieństwa: całkowicie biały fragment daje tu taki sam wynik jak pasująca krawędź, ponieważ piksele pod zerowymi wagami filtra nie wpływają na sumę. Wyuczone filtry mogą też używać wag ujemnych, by obniżać odpowiedź na niepożądane struktury.

Ta sama matematyka wyjaśnia też rozmycie. Jądro rozmycia ma każdą wartość równą ⅑, więc suma ważona to po prostu średnia z 9 pikseli. Na fragmencie z lewą krawędzią:

Jądro rozmycia (po 1/9) × fragment lewej krawędzi
kernelimage regionproducts
⅑
⅑
⅑
×
0
0
255
=
⅑×0+⅑×0+⅑×255
= 28.333
⅑
⅑
⅑
×
0
0
255
=
⅑×0+⅑×0+⅑×255
= 28.333
⅑
⅑
⅑
×
0
0
255
=
⅑×0+⅑×0+⅑×255
= 28.333
Total: 85

W tej jednej pozycji piksel wyjściowy wynosi ~85 — szarość między czernią (0) a bielą (255). Jądro jedzie dalej, przetwarzając obraz pozycja po pozycji, wiersz po wierszu. Każda pozycja w pobliżu krawędzi dostaje podobną wartość mieszaną, zmiękczając ostry skok z 0 na 255 w płynne przejście. Na jednolitym obszarze (całkiem czarnym albo całkiem białym) każdy piksel fragmentu jest taki sam, więc średnia równa się oryginałowi — tam nic się nie zmienia.

Rozmiar wyjścia

Kiedy filtr przesuwa się po wejściu bez dopełnienia, wyjście staje się mniejsze niż wejście — nazywa się to efektem brzegowym. Rozważ wejście 5×5 z jądrem 3×3. Jądro startuje w lewym górnym rogu, obejmując wiersze 0–2 i kolumny 0–2. Przesuwa się w prawo po jednym pikselu — ale może dojść tylko do kolumny 0, 1 albo 2. Start od kolumny 3 sprawiłby, że jądro objęłoby kolumny 3–5, a kolumna 5 nie istnieje. Tak samo w pionie. Zatem na oś przypadają tylko 3 dopuszczalne pozycje, co z wejścia 5×5 daje wyjście 3×3:

Dla jednego wymiaru, przy rozmiarze wejścia nn, filtra kk, paddingu pp z każdej strony, kroku ss i bez dylatacji, rozmiar wyjścia wynosi ⌊(n+2p−k)/s⌋+1\lfloor (n + 2p - k)/s \rfloor + 1. Tutaj p=0p=0 i s=1s=1, więc 5−3+1=35-3+1=3.

Dopełnienie

Żeby przeciwdziałać efektowi brzegowemu, można dodać wokół wejścia wiersze i kolumny zer — nazywa się to dopełnieniem (padding). Dla jądra 3×3 dodajesz 1 piksel zer z każdej strony, więc wejście 28×28 staje się dopełnionym wejściem 30×30, a wyjście wraca do 28×28. Dla jądra 5×5 dodałbyś po 2 piksele z każdej strony.

Dodajemy zera, a nie na przykład jedynki, bo zero pomnożone przez dowolną wartość jądra daje zero — dopełniające piksele nic nie wnoszą do iloczynu skalarnego. Na wynik wpływają tylko prawdziwe piksele nakładające się na jądro. To tak, jakby powiedzieć: „poza granicą obrazu nie ma nic”.

Krok konwolucji

Dotąd zakładaliśmy, że jądro przesuwa się o jeden piksel naraz — krok (stride) 1. Ale odległość między kolejnymi pozycjami jądra to parametr, który możesz zmieniać. Na przykład przy kroku 2 jądro skacze po 2 piksele, pomijając co drugą pozycję — zarówno poziomo, jak i pionowo. Większy krok oznacza mniej pozycji, co oznacza mniejsze wyjście.

Przy kroku 2 filtr przesuwa się o dwa piksele. Dla wejścia 5×5, filtra 3×3 i bez paddingu otrzymujemy wyjście 2×2 zamiast 3×3.

Zmniejszenie rozdzielczości ogranicza liczbę aktywacji i obliczeń w kolejnych warstwach. Zmniejsza też liczbę parametrów warstwy gęstej po spłaszczeniu. Liczba parametrów kolejnej konwolucji pozostaje jednak taka sama, jeśli rozmiar filtra i liczby kanałów się nie zmieniają.

Nasz model zmniejsza rozdzielczość za pomocą max poolingu, osobnej operacji wybierającej maksimum w każdym lokalnym oknie. Zaimplementujemy ją poniżej.

Wewnątrz warstwy konwolucyjnej

Dotąd patrzyliśmy na konwolucję jako operację samą w sobie — jedno jądro przesuwające się po obrazie i produkujące jedną mapę cech. Teraz zobaczmy, jak ta operacja jest opakowana w warstwę, którą sieć może trenować. W Kerasie warstwę konwolucyjną definiuje się tak:

layers.Conv2D(2, kernel_size=3, activation='relu', padding='same')

Uczone parametry to wagi filtrów i biasy. Mapy cech są aktywacjami obliczanymi dla każdego wejścia, a nie uczonymi parametrami. Podczas treningu implementacja zachowuje również wejścia i aktywacje potrzebne do propagacji wstecznej.

Zarówno warstwy gęste, jak i konwolucyjne biorą całe wejście, ale przetwarzają je inaczej. Weź uproszczoną siatkę 3×3 i jądro 2×2. Neuron gęsty ma unikalną wagę na każdy piksel (9 wag dla naszej prostej siatki 3×3 albo 784 wagi dla obrazu 28×28), liczy jedną sumę ważoną i produkuje jedną wartość.

Jądro konwolucyjne ma tylko kilka współdzielonych wag, ale przesuwa się po każdej pozycji obrazu, produkując jedną wartość na pozycję — całą mapę cech. W każdej pozycji warstwa wycina lokalny fragment wejścia, mnoży go element po elemencie przez jądro, sumuje wszystkie iloczyny i dodaje bias.

Neuron gęsty widzi wszystkie 9 pikseli naraz, mając 9 unikalnych wag, i produkuje jedno wyjście. Jądro konwolucyjne widzi po 4 piksele naraz (2×2), ale odwiedza każdą pozycję — te same 4 wagi, używane wszędzie. Mniej parametrów, a cały obraz i tak zostaje pokryty.

Jest przydatny sposób czytania tego obrazka z przesuwaniem. Każda pozycja odwiedzona przez jądro daje jedną wartość wyjściową — i każdą pozycję można traktować jako osobny neuron. Jądro w lewym górnym rogu to jeden neuron, patrzący tylko na lewy górny fragment; jądro krok w prawo to inny neuron, patrzący na fragment przesunięty o piksel; i tak dalej. Dla wejścia 3×3 i jądra 2×2 powyżej jądro ląduje w 4 pozycjach — więc ta malutka warstwa to w praktyce 4 neurony, których wyjścia składają się w mapę cech 2×2. Przeskaluj to, a liczba rośnie razem z obrazem: wejście 28×28 z jądrem 3×3 staje się warstwą 26×26 = 676 neuronów.

Mały fragment, na który patrzy każdy neuron, ma swoją nazwę: jego pole recepcyjne — własny obszar zainteresowania w wejściu. Polem recepcyjnym lewego górnego neuronu jest lewy górny róg 2×2 i reaguje on tylko na te piksele, całkowicie ignorując resztę obrazu. Każdy neuron ma inne pole recepcyjne, a razem pokrywają one całe wejście.

Neuron gęsty ma własne wagi obejmujące całe wejście. Wyjście konwolucji korzysta z lokalnego pola recepcyjnego i współdzieli wagi z innymi pozycjami w tym samym kanale wyjściowym. Cztery pozycje powyżej to ten sam filtr zastosowany w czterech miejscach; pozostałe kanały mają własne filtry.

Współdzielenie wag sprawia, że konwolucja z krokiem 1 jest ekwiwariantna względem przesunięć, z pominięciem efektów brzegowych: przesunięcie wejścia przesuwa mapę cech. Nie zapewnia to niezmienności klasyfikatora. Pooling może ograniczyć wrażliwość na niektóre małe przesunięcia, ale przekroczenie granicy okna może zmienić wynik.

A gdy warstwy się nakładają, pole recepcyjne neuronu rośnie — neuron drugiej warstwy czyta fragment wyjść pierwszej warstwy, z których każde już podsumowało jakiś fragment pikseli, więc pośrednio widzi szerszy obszar oryginalnego obrazu. To właśnie to „szersze pole widzenia”, do którego wrócimy przy kroku i głębokości.

Oto implementacja w NumPy dla jednego kanału wejściowego, kroku 1 i filtrów o nieparzystym rozmiarze. Wyjście ma układ (channels, height, width). Keras domyślnie używa (height, width, channels). Zaczynamy od import numpy as np:

import numpy as np

class Conv2D:
    def __init__(self, num_filters, kernel_size, padding='same'):
        if padding not in ('same', 'valid') or kernel_size % 2 == 0:
            raise ValueError("Use an odd kernel size and 'same' or 'valid' padding")
        self.kernels = np.random.randn(num_filters, kernel_size, kernel_size) * 0.1
        self.biases = np.zeros(num_filters)
        self.padding = (kernel_size - 1) // 2 if padding == 'same' else 0

    def forward(self, input):
        # Pad input with zeros if padding='same'
        if self.padding > 0:
            p = self.padding
            input = np.pad(input, ((p, p), (p, p)), mode='constant')
        self.input = input                           # save padded input for backward

        h, w = input.shape
        k = self.kernels.shape[1]
        out_h, out_w = h - k + 1, w - k + 1
        self.z = np.zeros((self.kernels.shape[0], out_h, out_w))

        for f in range(len(self.kernels)):           # each filter
            for r in range(out_h):                   # each row
                for c in range(out_w):               # each column
                    patch = input[r:r+k, c:c+k]      # extract local patch
                    self.z[f, r, c] = np.sum(patch * self.kernels[f]) + self.biases[f]

        self.out = np.maximum(0, self.z)             # ReLU activation
        return self.out  # shape: (num_filters, out_h, out_w)

Suma ważona w każdej pozycji plus składnik biasu — to cała operacja. Bias (jeden na jądro) przesuwa wyjście w górę lub w dół, dając jądru próg tego, jak mocno wzorzec musi pasować, zanim ono się aktywuje. Działa dokładnie jak bias w warstwie gęstej.

Jak uczą się jądra: propagacja wsteczna w CNN

Propagacja wsteczna oblicza gradienty, a optymalizator używa ich do aktualizacji wag. Dla jednej próbki waga warstwy gęstej uczestniczy w jednym obliczeniu wyjścia. Waga konwolucji jest używana w wielu pozycjach, więc jej gradient sumuje wkłady ze wszystkich tych miejsc. Obie warstwy sumują również gradienty próbek w mini-batchu.

# For one sample and one dense weight:
dw = input_value * output_gradient

# For one convolution weight (kr, kc), sum over output positions:
dw = sum(
    padded_input[r + kr, c + kc] * gradient[r, c]
    for r in range(out_h)
    for c in range(out_w)
)

Tutaj gradient[r, c] jest pochodną straty względem wartości przed aktywacją w danej pozycji wyjściowej, po uwzględnieniu pochodnej ReLU. Każdy składnik to wartość wejściowa pomnożona przez odpowiadający jej gradient wyjścia.

Oto jak ta kumulacja wygląda w naszej klasie Conv2D:

class Conv2D:
    # ... __init__ and forward from above ...

    def backward(self, upstream_gradient):
        # ReLU backward: zero gradient where activation was ≤ 0
        grad = upstream_gradient * (self.z > 0)

        k = self.kernels.shape[1]
        out_h, out_w = grad.shape[1], grad.shape[2]

        self.grad_biases = grad.sum(axis=(1, 2))
        self.grad_kernels = np.zeros_like(self.kernels)
        for f in range(len(self.kernels)):
            for kr in range(k):
                for kc in range(k):
                    for r in range(out_h):            # sum over every position
                        for c in range(out_w):
                            self.grad_kernels[f][kr][kc] += self.input[r+kr][c+kc] * grad[f][r][c]

    def update(self, lr):
        self.kernels -= lr * self.grad_kernels
        self.biases -= lr * self.grad_biases

Gradient biasu to suma grad po pozycjach przestrzennych. Ta implementacja pierwszej warstwy oblicza tylko gradienty parametrów; łączenie kilku trenowanych warstw konwolucyjnych wymagałoby także zwracania gradientu względem wejścia.

Wiele jąder, wiele map cech

Jeden filtr tworzy jedną mapę cech: mapę odpowiedzi na jego wagi. Różne filtry mogą reagować na różne struktury, choć pojedynczy filtr nie musi odpowiadać jednemu łatwemu do nazwania wzorcowi.

Input (28×28)
→
Filter 1: horizontal edges
Filter 2: vertical edges

Stąd konieczność użycia wielu jąder na warstwę, żeby wyprodukować wiele map cech — po jednej na jądro, każda wykrywająca inny wzorzec. Zatem każda warstwa konwolucyjna produkuje tyle map cech, ile ma jąder — u nas 2, ale to celowe minimum.

Większa liczba filtrów zwiększa liczbę kanałów wyjściowych i pojemność modelu. Trening może dać filtry uzupełniające się, ale także redundantne lub nieaktywne.

Oto co dostaliśmy po wytrenowaniu naszej CNN z dwoma filtrami — wartości jąder, do których zbiegła, dają widocznie różne mapy cech dla kwadratów i kół. To mapy cech z naszej jedynej warstwy konwolucyjnej; w głębszej sieci każda warstwa produkowałaby własny zestaw map cech, a głębsze warstwy chwytałyby coraz bardziej złożone wzorce:

Feature map 1
Feature map 2
Feature map 1
Feature map 2

Te mapy używają wag i biasów z treningu opisanego poniżej, pikseli znormalizowanych do [0, 1] i aktywacji ReLU. Jasność każdej mapy jest skalowana niezależnie, więc porównuj układ przestrzenny, a nie bezwzględną jasność między mapami.

Warstwa gęsta otrzymuje odpowiedzi z obu map po poolingu. Mogą one pomóc rozdzielić klasy, nawet jeśli żaden filtr nie jest wyspecjalizowanym „detektorem kwadratów” lub „detektorem okręgów”.

Warstwy w stosie: od krawędzi do kształtów

Jedna warstwa konwolucyjna z dwoma filtrami wystarcza dla tego wygenerowanego zbioru. Łączenie warstw pozwala kolejnym filtrom zestawiać wcześniejsze odpowiedzi w większych polach recepcyjnych, co może pomóc przy bardziej zróżnicowanych obrazach.

Prawdziwa siła CNN bierze się z układania warstw konwolucyjnych w stos. Żeby zobaczyć dlaczego, pomyśl, jak rozłożyłbyś problem rozpoznawania ręcznie. Gdyby ktoś poprosił Cię o wykrycie cyfry „0”, mógłbyś rozbić to na podproblemy: czy jest górny łuk? dolny łuk? lewa krawędź? prawa krawędź? Każda podsieć odpowiada na jedno pytanie, a końcowa warstwa łączy ich wyjścia:

Każdy z tych podproblemów można rozłożyć dalej. „Górny łuk?” rozpada się na: czy jest łuk lewy górny? łuk prawy górny? pozioma krawędź na szczycie? Każde pytanie robi się prostsze, bliższe surowym cechom wizualnym:

Schemat krawędzie → krzywe → części ilustruje rosnące pola recepcyjne, ale nie gwarantuje, czego nauczy się każda warstwa. Rzeczywiste cechy zależą od danych, architektury i treningu.

A na każdym poziomie równolegle pracuje wiele jąder — jedno może nauczyć się wykrywać krawędzie poziome, gdy inne wykrywa pionowe, jedno znajduje kąty proste, gdy inne gładkie łuki. Dlatego każda warstwa ma wiele jąder — sieć musi śledzić wiele różnych wzorców jednocześnie na każdym szczeblu hierarchii.

Wiele kanałów wejściowych

Wszystko powyżej opisuje pierwszą warstwę konwolucyjną w hierarchii, gdzie każde jądro dostaje na wejściu pojedynczy obraz 2D. Ale głębsze warstwy stosu pracują z czymś innym. Pierwsza warstwa konwolucyjna bierze obraz w odcieniach szarości — pojedynczą siatkę 2D 28×28. Ale wejście drugiej warstwy konwolucyjnej to już nie płaski obraz 2D — to wyjście pierwszej warstwy, stos map cech 2D, czyli objętość 3D (28×28×2 w naszym przypadku albo 28×28×32 w typowej sieci):

Każda wyjściowa mapa cech staje się wejściowym kanałem następnej warstwy. Kanały są równoległymi składowymi w tych samych pozycjach przestrzennych. Ich kolejność musi odpowiadać wagom kolejnej warstwy; zamiana kanałów bez zamiany odpowiednich wag zmienia obliczenia.

Skoro wejście ma teraz wiele ułożonych map cech (wiele kanałów), jądro musi przetworzyć je wszystkie. Jądro automatycznie odzwierciedla strukturę wejścia — jego głębokość (liczba plastrów) jest wnioskowana tak, by pasowała do liczby kanałów wejściowych. Każda warstwa nadal może — i zwykle ma — wiele jąder, ale teraz każde jądro staje się trójwymiarowe: jego głębokość automatycznie odpowiada liczbie kanałów wejściowych.

Przy 2 kanałach wejściowych każde jądro staje się stosem dwóch macierzy 2×2, po jednej na kanał. W każdej pozycji pierwsza macierz mnoży się z kanałem 1, druga z kanałem 2, a wszystkie iloczyny sumują się do jednej wartości wyjściowej na pozycję. Przesuwanie takiego jądra 3D po wszystkich pozycjach daje jako wyjście jednego jądra dokładnie jedną mapę cech — po jednej wartości na pozycję.

Diagram poniżej pokazuje dwie pozycje, żeby zilustrować, jak to samo jądro produkuje różne wartości, które wszystkie składają się na tę jedną mapę cech:

Operacja to ta sama suma ważona, którą już znamy — tylko wykonana raz na kanał, a potem zsumowana. Diagram pokazuje jądro 2×2 przesuwające się po wejściu 3×3 z 2 kanałami. W każdej pozycji liczymy sumę ważoną osobno dla każdego kanału, a potem dodajemy wyniki:

Position (0, 0)
ch 1:0×1 + 1×0 + 3×0 + 4×1= 4
ch 2:1×0 + 2×1 + 4×2 + 5×3= 25
4 + 25 = 29
output feature map
29
...
...
61
Position (1, 1)
ch 1:4×1 + 5×0 + 7×0 + 8×1= 12
ch 2:5×0 + 6×1 + 8×2 + 9×3= 49
12 + 49 = 61

W standardowej konwolucji każdy filtr wyjściowy obejmuje wszystkie kanały wejściowe. Liczba jego parametrów to kernel_height × kernel_width × input_channels + 1, gdzie ostatni składnik to bias. Sama głębokość sieci nie powiększa filtra; robi to zmiana liczby kanałów wejściowych.

Pooling

Po tym, jak warstwa konwolucyjna wykryje cechy, chcemy zmniejszyć każdą mapę cech — jak przy skalowaniu obrazu w dół, tylko że zamiast uśredniać piksele zachowujemy jedynie najsilniejsze sygnały. Każda mapa cech jest zmniejszana niezależnie — pooling nie scala kanałów. Głębokość zostaje ta sama: 28×28×2 staje się 14×14×2, a nie 14×14×1.

Pooling nie ma uczonych wag. Podsumowuje lokalne okno niezależnie w każdym kanale. Max pooling zachowuje największą wartość; może tolerować niektóre przesunięcia wewnątrz okna, lecz nie zapewnia ogólnej niezmienności względem przesunięć.

Istnieją różne strategie poolingu — maksimum, średnia i inne — ale najczęstszą jest max pooling: weź okno 2×2 i przesuwaj je po mapie cech z krokiem 2 — skoro krok równa się rozmiarowi okna, każde okno obejmuje świeży obszar bez nakładania. W każdej pozycji zachowaj tylko wartość maksymalną.

Pooling może brzmieć jak konwolucja z krokiem — oba przesuwają okno i zmniejszają rozdzielczość. Różnią się jednak na dwa istotne sposoby. Po pierwsze, nic się nie uczy — pooling nie ma wag, nie ma jądra, nie ma parametrów aktualizowanych podczas treningu. Po drugie, sama operacja jest inna — zamiast sumy ważonej (pomnóż i zsumuj) pooling po prostu wybiera maksimum.

W naszym modelu pooling sprowadza mapę cech 28×28 do 14×14:

Square
Feature map 1
Feature map 2
→
Feature map 1
Feature map 2
Circle
Feature map 1
Feature map 2
→
Feature map 1
Feature map 2

Jeśli odpowiedź przesunie się wewnątrz tego samego okna i pozostanie jego maksimum, wynik poolingu się nie zmieni. Przekroczenie granicy okna może jednak zmienić wyjście. Pooling daje ograniczoną tolerancję na przesunięcia, a nie gwarancję niezmienności predykcji przy ruchu o jeden piksel.

Average pooling oblicza średnią w każdym oknie zamiast maksimum. Global average pooling sprowadza całą mapę cech do jednej wartości na kanał. Klasyfikator może korzystać z tych wartości, często przez końcową warstwę gęstą, zamiast spłaszczać wszystkie pozycje przestrzenne.

Nasz MaxPool2D używa niepokrywających się okien i pomija końcowe wiersze lub kolumny, które nie tworzą pełnego okna. W propagacji wstecznej gradient trafia do maksimum okna; przy remisie wybierane jest pierwsze maksimum. Nie ma parametrów do aktualizacji:

class MaxPool2D:
    def __init__(self, size=2):
        self.size = size

    def forward(self, x):
        self.input = x                             # save for backward
        s = self.size
        channels, h, w = x.shape
        out = np.zeros((channels, h // s, w // s))
        for c in range(channels):
            for r in range(0, h - s + 1, s):
                for col in range(0, w - s + 1, s):
                    out[c, r//s, col//s] = np.max(x[c, r:r+s, col:col+s])
        self.output_shape = out.shape
        return out

    def backward(self, gradient):
        s = self.size
        out = np.zeros_like(self.input)
        channels, h, w = self.input.shape
        for c in range(channels):
            for r in range(0, h - s + 1, s):
                for col in range(0, w - s + 1, s):
                    patch = self.input[c, r:r+s, col:col+s]
                    max_idx = np.unravel_index(np.argmax(patch), patch.shape)
                    out[c, r+max_idx[0], col+max_idx[1]] = gradient[c, r//s, col//s]
        return out
    # No update() — pooling has no parameters

Spłaszczenie i klasyfikacja

Pooling przetwarza kanały niezależnie, a konwolucja może je łączyć. W tym klasyfikatorze spłaszczamy wszystkie aktywacje do jednego wektora, aby wyjście warstwy gęstej łączyło się z każdą pozycją w każdym kanale. Dense w Keras może przyjmować tensory wyższego rzędu, ale działa wtedy na ostatniej osi, zamiast automatycznie spłaszczać wejście.

Nasze tablice NumPy mają kanały na pierwszej osi: spłaszczenie odczytuje 196 wartości kanału 1, a następnie 196 wartości kanału 2. Keras domyślnie umieszcza kanały na ostatniej osi, więc kolejność po spłaszczeniu jest inna. Obie konwencje działają, jeśli przejście w przód i propagacja wsteczna używają tego samego układu.

To ta sama zmiana kształtu, którą zrobiliśmy w modelu MNIST wyłącznie z warstwami gęstymi, gdy spłaszczyliśmy 28×28 do 784. Sam Flatten nie wie i nie dba o to, co przekształca — operacja jest identyczna. Różnica tkwi w tym, co zrobiły warstwy przed nim: w podejściu wyłącznie gęstym nie było warstw poprzedzających, więc Flatten dostawał surowe piksele. Tutaj konwolucja i pooling już wyodrębniły i skompresowały wzorce przestrzenne, więc warstwa gęsta dostaje sensowne cechy, a nie surowe wartości pikseli.

Następnie warstwa gęsta działa jako klasyfikator. Ponieważ mamy tylko dwie klasy (kwadrat vs koło), jest to klasyfikacja binarna — potrzebujemy zaledwie jednego neuronu z aktywacją sigmoidalną. Łączy się on ze wszystkimi 392 wartościami, mnoży przez wyuczone wagi, sumuje, dodaje bias i wypuszcza jedną liczbę między 0 a 1: prawdopodobieństwo, że wejściem jest koło. Blisko 0 = kwadrat, blisko 1 = koło. Dla problemów wieloklasowych jak MNIST (10 cyfr) użyłbyś zamiast tego 10 neuronów z softmaxem — po jednym na klasę.

Oto klasa DenseLayer — ta sama struktura co HiddenLayer z poprzedniego artykułu:

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

    def forward(self, x):
        self.x = x
        return self.W @ x + self.b

    def backward(self, grad_output):
        self.grad_W = np.outer(grad_output, self.x)
        self.grad_b = grad_output
        return self.W.T @ grad_output

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

Budujemy model CNN dla kształtów

Skoro omówiliśmy już każdy element, zbudujmy model i pętlę treningową — tak samo jak zrobiliśmy to dla gęstego modelu MNIST, ale teraz z warstwami konwolucyjnymi.

Tworzymy instancje warstw, które zbudowaliśmy w tym artykule:

# 28×28 grayscale → 2 feature maps → pooling → flatten → 1 output
conv = Conv2D(num_filters=2, kernel_size=3)   # 20 parameters (2×9 weights + 2 biases)
pool = MaxPool2D(size=2)                       # 0 parameters
dense = DenseLayer(n_inputs=392, n_outputs=1) # 393 parameters (392 weights + 1 bias)

Przygotowanie danych

Generujemy 4000 obrazów: 2000 kwadratów i 2000 okręgów. Obie klasy mają ten sam zakres rozmiarów, margines, grubość linii i regułę wygładzania krawędzi, aby ograniczyć skróty wynikające z różnic w renderowaniu. To nadal celowo proste zadanie syntetyczne.

np.random.seed(42)
SIZE = 28
LINE_WIDTH = 2.0
rr, cc = np.mgrid[0:SIZE, 0:SIZE] + 0.5    # grid of pixel centers

def make_shape(kind):
    radius = np.random.uniform(4.0, 10.0)
    margin = radius + 2.0
    cy, cx = np.random.uniform(margin, SIZE - margin, size=2)
    dy, dx = np.abs(rr - cy), np.abs(cc - cx)
    distance = np.maximum(dy, dx) if kind == 'square' else np.hypot(dy, dx)
    edge_distance = np.abs(distance - radius)
    coverage = np.clip((LINE_WIDTH / 2 + 0.5 - edge_distance) / 0.5, 0, 1)
    return (255 * coverage).astype(np.float32)

N = 2000
squares = np.array([make_shape('square') for _ in range(N)])
circles = np.array([make_shape('circle') for _ in range(N)])
Dlaczego używamy np.mgrid?
np.mgrid tworzy tablice współrzędnych pikseli. make_shape oblicza następnie odległości i pokrycie krawędzi dla wszystkich pikseli operacjami tablicowymi NumPy, bez pętli Pythona po pojedynczych pikselach.

Normalizujemy piksele do [0, 1], a następnie dzielimy każdą klasę osobno: 1440 obrazów do treningu, 160 do walidacji i 400 do testów. Tasowanie w każdym podzbiorze daje zrównoważone zbiory liczące 2880, 320 i 800 obrazów. Zbiór testowy odkładamy do zakończenia treningu.

X = np.concatenate([squares, circles]) / 255.0  # normalize to [0, 1]
y = np.concatenate([np.zeros(N), np.ones(N)])    # 0 = square, 1 = circle

# Split each class separately, then shuffle each subset.
splits = [[], [], []]
for label in (0, 1):
    class_indices = np.random.permutation(np.flatnonzero(y == label))
    for target, part in zip(splits, np.split(class_indices, [1440, 1600])):
        target.extend(part)
train_idx, val_idx, test_idx = [np.random.permutation(part) for part in splits]
X_train, y_train = X[train_idx], y[train_idx]
X_val, y_val = X[val_idx], y[val_idx]
X_test, y_test = X[test_idx], y[test_idx]

Zauważ, że nie spłaszczamy obrazów — w odróżnieniu od gęstego modelu MNIST, gdzie przekształcaliśmy je do wektorów 784, warstwa konwolucyjna potrzebuje nienaruszonej struktury przestrzennej 2D.

Aktywacja na wyjściu

Nasz model wypuszcza jedną liczbę — prawdopodobieństwo, że wejściem jest koło. Żeby zamienić surowe wyjście warstwy gęstej (które może być dowolną wartością) w prawdopodobieństwo od 0 do 1, używamy sigmoidy:

def sigmoid(x):
    z = np.exp(-np.abs(x))
    return np.where(np.asarray(x) >= 0, 1 / (1 + z), z / (1 + z))

To ta sama funkcja, której użylibyśmy w regresji logistycznej — ściska dowolną wartość do przedziału (0, 1). Dla problemów wieloklasowych jak MNIST użylibyśmy zamiast niej softmaxu.

Funkcja straty

Żeby zmierzyć, jak bardzo predykcja się myli, używamy binarnej entropii krzyżowej — tej samej idei co strata entropii krzyżowej z artykułu o MNIST, tylko dostosowanej do dwóch klas zamiast dziesięciu:

def binary_cross_entropy(predicted, label):
    # Clip probabilities to keep log(0) out of the reported loss.
    p = np.clip(predicted, 1e-10, 1 - 1e-10)
    return -label * np.log(p) - (1 - label) * np.log(1 - p)

Dla okręgu predykcja 0,95 daje mniejszą stratę niż 0,3. Przycięcie prawdopodobieństw zapewnia skończony wynik funkcji raportującej także dla 0 i 1. Propagacja wsteczna poniżej używa analitycznego gradientu sigmoid + BCE, p - y, przed przycięciem; implementacje produkcyjne zwykle obliczają BCE bezpośrednio z logitów dla stabilności numerycznej.

Pętla treningowa

Po wygenerowaniu i podziale danych tworzymy model ponownie, tak jak w notatniku. Przejście w przód używa tablic z kanałami na pierwszej osi:

conv = Conv2D(num_filters=2, kernel_size=3)
pool = MaxPool2D(size=2)
dense = DenseLayer(n_inputs=392, n_outputs=1)

def forward(image):
    activated = conv.forward(image)      # (28, 28) → (2, 28, 28)
    pooled = pool.forward(activated)     # (2, 28, 28) → (2, 14, 14)
    flat = pooled.reshape(-1)            # (2, 14, 14) → (392,)
    return sigmoid(dense.forward(flat))[0]

Przejście wstecz odbija je w odwrotnej kolejności — od gradientu straty z powrotem przez warstwę gęstą, od-spłaszczenie, pooling i konwolucję. Dlaczego output - label jest poprawnym gradientem startowym dla sigmoidy + binarnej entropii krzyżowej, rozbierzemy dokładnie w kolejnym artykule:

def backward(output, label):
    grad = np.array([output - label])                      # sigmoid + BCE gradient
    grad = dense.backward(grad)                 # dense layer
    grad = grad.reshape(pool.output_shape)             # un-flatten
    grad = pool.backward(grad)                  # pooling: route to max positions
    conv.backward(grad)                         # conv: ReLU + accumulate across positions

Sumujemy gradienty poszczególnych obrazów i uśredniamy je przed każdą aktualizacją SGD. Notatnik oblicza też stratę walidacyjną po każdej epoce i ocenia zbiór testowy raz, na końcu. Zwektoryzowana konwolucja i pooling wykonują te same operacje co pokazane tutaj pętle.

def train(X_train, y_train, epochs=20, lr=0.25, batch_size=32):
    for epoch in range(epochs):
        indices = np.random.permutation(len(X_train))

        for i in range(0, len(X_train), batch_size):
            batch_idx = indices[i:i+batch_size]
            bs = len(batch_idx)

            # Accumulate gradients over the mini-batch
            acc_conv_k = np.zeros_like(conv.kernels)
            acc_conv_b = np.zeros_like(conv.biases)
            acc_dense_W = np.zeros_like(dense.W)
            acc_dense_b = np.zeros_like(dense.b)

            for idx in batch_idx:
                # 1. Forward pass
                output = forward(X_train[idx])
                # 2. Loss
                loss = binary_cross_entropy(output, y_train[idx])
                # 3. Backpropagation (computes per-sample gradients)
                backward(output, y_train[idx])
                # Accumulate
                acc_conv_k += conv.grad_kernels
                acc_conv_b += conv.grad_biases
                acc_dense_W += dense.grad_W
                acc_dense_b += dense.grad_b

            # 4. Average gradients and update weights
            conv.grad_kernels = acc_conv_k / bs
            conv.grad_biases = acc_conv_b / bs
            dense.grad_W = acc_dense_W / bs
            dense.grad_b = acc_dense_b / bs
            conv.update(lr)
            dense.update(lr)
Pobierz notatnik NumPy do uruchomienia

Wyniki treningu

Poniższy trening obejmuje 20 epok, 2880 obrazów treningowych i 320 walidacyjnych. Metryki treningowe są zbierane w trakcie epoki; metryki walidacyjne używają wag z jej końca. Osobny zbiór 800 obrazów testowych nie służy do aktualizacji wag ani do wykresu walidacji.

Końcowa dokładność na odłożonym zbiorze testowym jest podana pod wykresem. Notatnik do pobrania eksportuje zarówno te metryki, jak i wagi użyte w demonstracji map cech. Wyniki na wygenerowanych kształtach nie dowodzą odporności na inne style rysowania ani rzeczywiste obrazy.

Ta sama architektura w Keras przyjmuje wejście z kanałami na ostatniej osi. Ta wersja używa Adama zamiast SGD z notatnika, więc przebieg treningu będzie inny:

from tensorflow import keras
from tensorflow.keras import layers

model = keras.Sequential([
    layers.Input(shape=(28, 28, 1)),
    layers.Conv2D(2, kernel_size=3, activation='relu', padding='same'),
    layers.MaxPooling2D(pool_size=2),
    layers.Flatten(),
    layers.Dense(1, activation='sigmoid'),
])
model.compile(optimizer='adam', loss='binary_crossentropy', metrics=['accuracy'])
model.fit(X_train[..., None], y_train, epochs=20, batch_size=32,
          validation_data=(X_val[..., None], y_val))

Skalowanie w górę: od kształtów do MNIST

Nasz klasyfikator kształtów ma zaledwie 413 parametrów, bo zadanie jest proste. Prawdziwa klasyfikacja obrazów wymaga większej pojemności. Oto typowa CNN do rozpoznawania cyfr MNIST — te same zasady, tylko większe:

model = keras.Sequential([
    layers.Input(shape=(28, 28, 1)),
    layers.Conv2D(32, kernel_size=3, activation='relu'),  # 28×28×1 → 26×26×32
    layers.MaxPooling2D(pool_size=2),                      # 26×26×32 → 13×13×32

    layers.Conv2D(64, kernel_size=3, activation='relu'),   # 13×13×32 → 11×11×64
    layers.MaxPooling2D(pool_size=2),                      # 11×11×64 → 5×5×64

    layers.Flatten(),                                       # 5×5×64 → 1600
    layers.Dense(128, activation='relu'),                   # 1600 → 128
    layers.Dense(10, activation='softmax'),                 # 128 → 10
])

Różnice względem naszego modelu-zabawki:

  • Wejście 28×28 tego samego rozmiaru — ale ręcznie pisane cyfry w odcieniach szarości zamiast prostych figur geometrycznych
  • 32 i 64 filtry zamiast 2 — o wiele więcej wzorców do wykrycia
  • Dwa bloki konwolucja+pooling zamiast jednego — hierarchiczna ekstrakcja cech
  • Softmax na 10 klas zamiast binarnej sigmoidy — 10 cyfr do rozróżnienia
  • ~225 000 parametrów zamiast 413 — dużo większa pojemność

Ale klocki są identyczne: przesuwaj małe filtry, by zbudować mapy cech, zrób pooling, by zmniejszyć rozmiar, spłaszcz, sklasyfikuj. Oto jak trening postępuje przez 5 epok:

Zapisany trening MNIST osiąga około 99,08% dokładności walidacyjnej po 5 epokach. Wykres pokazuje metryki walidacyjne, a nie osobną końcową ocenę testową. Porównanie z wcześniejszą siecią gęstą wymaga oceny obu modeli na tym samym odłożonym zbiorze testowym.