Zrozumieć CNN — konwolucje, mapy cech i pooling
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 wag + biasów = 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 świadomości przestrzennej. Warstwa gęsta traktuje wszystkie 784 wejścia jako nieuporządkowaną listę — piksele (5, 10) i (5, 11) sąsiadują ze sobą na obrazie, ale nic w strukturze warstwy tego faktu nie wykorzystuje. Spłaszczenie wyrzuciło całą strukturę przestrzenną — piksel (0, 0) i piksel (27, 27) to po prostu dwie liczby w wektorze. Jeśli cyfra przesunie się o kilka pikseli w prawo, dla sieci gęstej wygląda zupełnie inaczej — te same wartości pikseli trafiają teraz na inne indeksy wejściowe i aktywują inne wagi. Sieć musi uczyć się tego samego wzorca dla każdej możliwej pozycji z osobna.
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 pikseli kanały kolorów wartości. Jedna warstwa gęsta z 512 neuronami potrzebowałaby milionów parametrów — i to na jedną warstwę. To drogie w trenowaniu i łatwo przeuczalne.
Dlaczego zbyt wiele parametrów prowadzi do przeuczenia?
Więcej parametrów to większa pojemność na zapamiętywanie. Przy 77 milionach wag i, powiedzmy, tylko 50 tysiącach obrazów treningowych sieć ma znacznie więcej parametrów niż punktów danych. Zamiast uczyć się ogólnych prawidłowości w rodzaju „pętle znaczą 0, proste linie znaczą 1”, może w zasadzie zapamiętać każdy obraz treningowy dosłownie — osiągając idealną trafność na treningu i zawodząc na nowych, niewidzianych obrazach.
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.
Poniższy diagram to pokazuje. Wyobraź sobie, że musimy odróżnić kwadraty od kół na siatce 28×28. Do tego trzeba wykrywać lokalne różnice — w rogu kwadratu widzisz ostry kąt prosty, a na krawędzi koła łagodne wygięcie. Te lokalne fragmenty to dokładnie to, czego CNN uczy się wykrywać:
Użycie CNN pozwala zaadresować każdy z problemów wprost:
- Świadomość przestrzenna: ponieważ filtr działa na lokalnym obszarze 2D, w naturalny sposób rozumie, że sąsiednie piksele są ze sobą powiązane. Wykrywa wzorce takie jak krawędzie i łuki na podstawie tego, jak piksele są rozmieszczone przestrzennie — a nie tylko na podstawie ich pozycji na płaskiej liście.
- Znacznie mniej parametrów: filtr 3×3 ma zaledwie 9 parametrów (plus bias), a ten sam filtr jest wykorzystywany w każdej pozycji obrazu. Nazywa się to współdzieleniem wag. Jeśli filtr nauczy się wykrywać prostą krawędź, wykryje ją niezależnie od tego, czy pojawi się po lewej, czy po prawej stronie — sieć gęsta potrzebowałaby osobnych wag dla każdej pozycji.
CNN to architektura, która umożliwiła nowoczesne widzenie komputerowe — dała przełomy w wyszukiwaniu obrazów, rozpoznawaniu sceny w aparatach telefonów, OCR i analizie wideo, i wciąż jest szeroko używana w jeździe autonomicznej, obrazowaniu medycznym i robotyce (choć najnowocześniejsze systemy coraz częściej sięgają po Vision Transformery (ViT) albo podejścia hybrydowe). Na przykład PaddleOCR — jeden z najpopularniejszych otwartoźródłowych systemów OCR — używa CNN jako kluczowej części swojej architektury do detekcji i rozpoznawania tekstu.
Poza obrazami ta sama idea stosuje się do danych 1D — przesuwane filtry po przebiegach dźwiękowych dla rozpoznawania mowy (wav2vec 2.0), generowania muzyki (WaveNet) i klasyfikacji audio. Nawet model językowy LFM2 od Liquid AI używa konwolucji 1D jako podstawowego klocka — 10 z jego 16 bloków to krótkozasięgowe warstwy konwolucyjne do lokalnego mieszania tokenów.
Konwolucja w sieciach neuronowych
Żeby zrozumieć CNN, tak naprawdę wystarczy pojąć dwie operacje: konwolucję (przesuwanie filtrów po obrazie w celu wykrywania wzorców) i pooling (zmniejszenie wyniku z zachowaniem najsilniejszych sygnałów). Stosuje się je naprzemiennie — konwolucja, pooling — a potem znane nam warstwy gęste zajmują się końcową klasyfikacją. Oto jak mógłby wyglądać kompletny model w Kerasie:
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:
Warstwy konwolucyjne i poolingu razem stanowią ekstraktor cech — destylują obraz do zwartej reprezentacji, w której kwadraty i koła wyglądają inaczej. Potem warstwa gęsta na końcu robi łatwą część: kreśli granicę decyzyjną w tej przestrzeni cech.
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.
Do końca tego artykułu każda linijka nabierze sensu, a Ty zobaczysz faktyczne wartości filtrów, których nauczyła się ta sieć. Zacznijmy od konwolucji.
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.
Spróbuj przełączać się między różnymi filtrami powyżej. Obserwuj przy tym dwie rzeczy: wartości jądra pośrodku (siatka 3×3 liczb) oraz to, jak zmienia się wyjście po prawej. Różne filtry dają różne przekształcenia:
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.
| Filtr | Jak działa |
|---|---|
| Rozmycie | Uśrednia wszystkie 9 pikseli po równo — wygładza różnice. Jednolity obszar zostaje taki sam, ale ostre przejścia się rozmywają |
| Wyostrzenie | Wzmacnia piksel centralny względem sąsiadów. W gładkich obszarach sąsiedzi się znoszą; na krawędziach niedopasowanie zostaje wyolbrzymione |
| Krawędzie poziome | Odejmuje 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 pionowe | Ta 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
Jest jeszcze inny sposób myślenia o konwolucji. Zamiast ręcznie zaprojektowanego jądra do wykrywania krawędzi czy rozmywania — co, jeśli jądrem będzie fragment samego obrazu? Możemy wziąć mały wycinek obrazu i zapytać: gdzie jeszcze pojawia się coś podobnego? To dopasowywanie wzorca — konwolucja znajduje, gdzie w całym obrazie występuje lokalny wzorzec.
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:
Poklikaj w różne miejsca — zauważ, jak każde daje inny układ dopasowań. To konwolucja jako przestrzenny detektor wzorców. Jądro definiuje, czego szukać, a wyjście mówi, gdzie zostało znalezione. To właśnie ta własność jest tym, co CNN naprawdę wykorzystują — nie rozmywanie czy wyostrzanie, lecz zdolność wykrywania wyuczonych wzorców w całym obrazie. Filtry rozmycia i krawędzi z poprzedniej sekcji były przydatne do zbudowania intuicji, ale w CNN wartości jądra są uczone automatycznie i działają jak detektory wzorców. Cała reszta CNN opiera się na tej jednej idei.
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:
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:
Na jednolitym czarnym obszarze każdy piksel to 0, więc każdy iloczyn to 0 — brak dopasowania w ogóle. Wyjście jest silne tylko tam, gdzie jądro i fragment obrazu się zgadzają. Rozwińmy, co się dzieje: spłaszczamy i jądro 3×3, i obszar obrazu 3×3 do wektorów 9-elementowych i liczymy ich iloczyn skalarny. Im bardziej podobne są oba wektory, tym większy wynik — dlatego pasujące obszary dają wysokie wartości, a niepasujące nie.
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ą:
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:
Pamiętaj: w każdej pozycji fragment 3×3 (9 wartości) zostaje skompresowany do jednej wartości wyjściowej przez sumę ważoną. To dzieje się zawsze. Wyjście kurczy się nie z powodu tej kompresji, lecz dlatego, że dopuszczalnych pozycji jest mniej niż pikseli wejściowych — jądro po prostu nie może zacząć tam, gdzie wystawałoby poza krawędź. Ogólny wzór to: . Dla naszych kształtów 28×28 (i cyfr MNIST, które mają ten sam rozmiar): .
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.
Rozważ to samo wejście 5×5 co wcześniej, ale teraz z krokiem 2. Jądro można umieścić tylko w pozycjach (0,0), (0,2), (2,0) i (2,2) — zaledwie 4 pozycje zamiast 9, co daje wyjście 2×2. Szerokość i wysokość zostają zmniejszone dwukrotnie:
Krok realizuje przestrzenne zmniejszanie rozdzielczości — redukcję szerokości i wysokości map cech. Jest to istotne, bo zmusza sieć do destylowania lokalnych wzorców do bardziej zwartych reprezentacji, redukuje liczbę parametrów w późniejszych warstwach i daje głębszym warstwom szersze pole recepcyjne na oryginalnym obrazie. Osiąga to przez zwykłe pomijanie pozycji podczas konwolucji — wyuczone jądro pełni podwójną rolę: detektora wzorców i reduktora rozdzielczości.
Jest jednak inna technika, częściej stosowana w sieciach klasyfikacyjnych: max-pooling. On też realizuje przestrzenne zmniejszanie rozdzielczości, ale poprzez osobną operację bez parametrów — zamiast pomijać pozycje podczas konwolucji, stosuje stałą regułę (weź maksimum), żeby zmniejszyć mapę cech już po zakończeniu konwolucji. Omówimy go w sekcji o poolingu 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')Choć w widgetach powyżej pokazywaliśmy zarówno jądra, jak i wyjściowe mapy cech, warstwa w rzeczywistości przechowuje tylko wartości jąder i biasy — nie mapy cech. Mapy cech są liczone w locie podczas przejścia w przód, przez przesuwanie zapamiętanych jąder po tym wejściu, które akurat przyszło. Mapy cech są wyjściami — podobnie jak aktywacje są wyjściami warstw gęstych, które przechowują tylko macierze wag, a nie same aktywacje.
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.
To tutaj kontrast z warstwami gęstymi staje się najostrzejszy. Neuron gęsty ma własne wagi i widzi całe wejście. „Neuron” konwolucyjny jest przeciwieństwem pod oboma względami: widzi tylko swoje małe pole recepcyjne i — co kluczowe — dzieli te same wagi z każdym innym neuronem w warstwie, czyli jądro. Powyższe 4 neurony to nie 4 niezależne detektory; to ten sam detektor policzony w 4 miejscach. Oto współdzielenie wag widziane od strony neuronu: jeden detektor wzorca, powielony na każde pole recepcyjne w obrazie.
To także naprawia problem przesunięcia, od którego zaczynaliśmy. Ponieważ ten sam detektor siedzi nad każdym polem recepcyjnym, wzorzec jest znajdowany gdziekolwiek się pojawi — krawędź w lewym górnym rogu zapala lewy górny neuron, a ta sama krawędź przesunięta w dół trafia w pole recepcyjne innego neuronu i zapala jego równie mocno. Cecha zostaje wykryta niezależnie od pozycji. Tę własność zwykle nazywa się niezmienniczością na przesunięcie, choć ściśle rzecz biorąc warstwa konwolucyjna jest ekwiwariantna — przesuń wejście, a mapa cech przesunie się razem z nim; to dopiero pooling zamienia drobne przesunięcia w prawdziwą niezmienniczość, w której drgnięcie o jeden piksel w ogóle nie zmienia wyjścia.
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 warstwy konwolucyjnej od zera, pokazująca całą operację:
class Conv2D:
def __init__(self, num_filters, kernel_size, padding='same'):
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
Wartości jąder startują losowo i są aktualizowane przez propagację wsteczną, tak samo jak w sieciach gęstych. Kluczową różnicą jest współdzielenie wag. W warstwie gęstej każda waga łączy się z jednym konkretnym wejściem i jest używana raz na przejście w przód — więc gradient każdej wagi pochodzi z jednej pary wejście × błąd. W warstwie konwolucyjnej te same 9 wag jest używane w każdej pozycji obrazu — więc podczas propagacji wstecznej każda pozycja wnosi gradient do tych samych wag. Poniższy fragment oddaje tę ideę:
# Dense layer: one weight, used once
dw = input * error # one gradient from one computation
# Conv layer: one weight, used at every position
dw[kr][kc] = sum over all (r, c):
input[r+kr][c+kc] * error[r][c] # same structure, summed across all positionsTutaj error to siatka wartości gradientu policzona przez warstwy powyżej podczas przejścia wstecznego — jej konkretne wartości zależą od funkcji straty i warstw pośrednich, które omówimy w kolejnych artykułach.
Ale struktura jest w obu przypadkach ta sama: wejście × błąd. Różnica polega na tym, że waga gęsta widzi jedną taką parę, a waga konwolucyjna kumuluje je z każdej pozycji, w której jądro zostało zastosowane.
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_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_biasesPorównaj to z metodą forward: te same zagnieżdżone pętle, tylko zamiast liczyć wartości wyjściowe, kumulujemy gradienty. Każda pozycja, w której jądro zostało zastosowane, dokłada się do tego samego gradientu — tak właśnie 9 wag może uczyć się z tysięcy pozycji w obrazie.
Wiele jąder, wiele map cech
Pojedyncze jądro potrafi wykryć tylko jeden typ wzorca. Detektor krawędzi poziomych znajduje krawędzie poziome, ale przegapia pionowe, rogi i łuki. Żeby uchwycić wiele aspektów wejścia, potrzeba wielu jąder pracujących równolegle. Oto ten sam kwadrat przetworzony przez dwa różne jądra — każde daje inną mapę cech, podświetlającą inną strukturę:
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.
Typowe sieci używają w pierwszej warstwie 32 albo 64 jąder. Każde jądro to osobna siatka 3×3 (rozmiar konfigurowalny) wyuczonych wartości, a wszystkie są stosowane do tego samego wejścia niezależnie i równolegle: jądro 1 przesuwa się po całym obrazie, produkując mapę cech 1, jądro 2 produkuje mapę cech 2, i tak dalej. Każde jądro uczy się wykrywać inny wzorzec — ta równoległość pozwala sieci uchwycić wiele aspektów wejścia jednocześnie, zamiast szukać tylko jednej rzeczy naraz.
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:
Każdy filtr wnosi do końcowej klasyfikacji inny aspekt. Filtr 1 (po lewej) działa jak szeroki detektor krawędzi — dla kwadratu daje ostre jasne i ciemne pasy wzdłuż każdego boku; dla koła gładki gradient wokół obwodu. Filtr 2 (po prawej) reaguje na rogi i zmiany kierunku — na kwadracie zapala się w czterech rogach; na kole daje naprzemienny wzór tam, gdzie krzywa się wygina.
Razem oba filtry tworzą charakterystyczny podpis każdego kształtu: mapy cech kwadratu mają ostre, zlokalizowane aktywacje na krawędziach i w rogach, a mapy koła są gładkie i równomiernie rozłożone wokół pierścienia. Warstwa gęsta na końcu uczy się czytać te podpisy — „ostre rogi” znaczy kwadrat, „gładki pierścień” znaczy koło.
Co zdumiewające, nie powiedzieliśmy filtrom, co mają wykrywać — oba wystartowały z różnymi losowymi wartościami, a spadek gradientu pchał każdy tam, gdzie strata malała najbardziej. Kończą wyspecjalizowane, bo gdyby oba filtry nauczyły się tego samego wzorca, jeden byłby nadmiarowy i nie pomagałby dalej obniżać straty. Presja minimalizacji straty w naturalny sposób rozsuwa filtry, a każdy chwyta inny aspekt wejścia.
Warstwy w stosie: od krawędzi do kształtów
Nasze zadanie kwadrat vs koło jest na tyle proste, że wystarcza jedna warstwa konwolucyjna — 2 filtry rozdzielają obie klasy. Ale do trudniejszych zadań, jak rozpoznawanie cyfr czy detekcja twarzy, potrzeba większej głębokości.
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:
Dokładnie tego uczą się automatycznie ułożone w stos warstwy konwolucyjne — hierarchicznej ekstrakcji cech. Pierwsza warstwa znajduje krawędzie, druga łączy krawędzie w łuki i rogi, głębsze warstwy składają z nich części i kształty. Sieci nie trzeba mówić, jakich cech szukać — uczy się całej hierarchii z danych.
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):
Te ułożone jedna na drugiej klatki często nazywa się kanałami. Każdy kanał to projekcja jednego jądra z warstwy wyżej — na przykład kanał 1 może być „krawędziowym” widokiem wejścia, a kanał 2 widokiem „rogów”. Zatem „kanał” i „mapa cech” to to samo, tylko oglądane z różnych stron: warstwa konwolucyjna wypuszcza mapy cech, a następna warstwa przyjmuje je jako kanały wejściowe. Słowa „kanał” używa się dlatego, że nie sugeruje żadnego uporządkowania ani hierarchii — to równoległe składowe tych samych danych, każda opisująca inny aspekt tego samego obszaru przestrzennego.
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:
W miarę pogłębiania sieci każde jądro robi się większe — jądro pierwszej warstwy to 3×3×1 (9 wag), a jądro drugiej warstwy z 2 kanałami wejściowymi to 3×3×2 (18 wag). Liczba kanałów zwykle rośnie w głąb sieci (1 → 2 → 32 → 64), więc jądra też stają się głębsze — podczas gdy rozmiar przestrzenny (3×3) zwykle pozostaje ten sam.
Wielokanałowe wejście daje głębszym warstwom zdolność łączenia wzorców z warstwy wyżej. Wróć myślą do map cech naszego modelu: filtr 1 wykrywał krawędzie, filtr 2 rogi. Gdybyśmy mieli drugą warstwę konwolucyjną, jej jądra patrzyłyby na obie mapy cech w każdej pozycji jednocześnie. Pozycja, w której filtr 1 znalazł krawędź pionową, a filtr 2 poziomą — to róg. Jądro drugiej warstwy uczy się łączyć te sygnały: „tu jest krawędź pionowa, a w tym samym miejscu jest też pozioma — to musi być róg”. Tak właśnie głębsze warstwy budują złożone cechy z prostszych.
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.
Warstwa poolingu dostaje stos map cech z warstwy konwolucyjnej, zmniejsza przestrzennie każdą z nich i przepuszcza tę samą liczbę kanałów do następnej warstwy. Żadnego scalania, żadnego uczenia — tylko kompresja przestrzenna. Służy to dwóm celom: mniej wartości znaczy mniej obliczeń w późniejszych warstwach, a do tego daje sieci niezmienniczość przestrzenną — cecha wykryta w pikselu (10, 12) i ta w pikselu (11, 12) przeżywają jako ta sama wartość po poolingu, co czyni sieć odporną na drobne przesunięcia.
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.
Pooling jest więc czysto przekształceniem — stałą regułą stosowaną do danych, bez niczego do nauczenia. W odróżnieniu od warstw konwolucyjnych i gęstych warstwy poolingu nie przechowują żadnych wag, żadnych biasów, żadnych parametrów. Dlatego w zestawieniu parametrów modelu pokazują 0. Można je traktować jako „krok kompresji” siedzący między warstwami trenowalnymi, redukujący rozmiar przestrzenny przy zachowaniu ważnych sygnałów.
W naszym modelu pooling sprowadza mapę cech 28×28 do 14×14:
Max pooling zachowuje najsilniejszą aktywację w każdym obszarze. Jeśli krawędź została wykryta gdzieś we fragmencie 2×2, maksimum zachowuje ten sygnał — nie ma znaczenia, który dokładnie z 4 pikseli dał najsilniejszą odpowiedź. Daje to sieci tolerancję na drobne przesunięcia: jeśli kształt przesunie się o jeden piksel, ten sam obszar po poolingu wciąż chwyta tę samą cechę. Kosztem jest utrata dokładnej informacji o pozycji — po poolingu wiesz, że cecha została wykryta gdzieś w tym obszarze 2×2, ale nie wiesz, w którym dokładnie pikselu.
Average pooling wygładza zamiast wyostrzać — bierze średnią wszystkich wartości w oknie. Daje to łagodniejsze wyjście, ale może rozcieńczać silne sygnały — pojedyncza mocna aktywacja krawędzi zostaje uśredniona ze słabszymi sąsiadami, tracąc na wpływie. Max pooling jest częstszy we wczesnych i środkowych warstwach, gdzie zachowanie silnych odpowiedzi ma znaczenie. Average pooling pojawia się czasem na samym końcu sieci jako globalny average pooling, który uśrednia całą mapę cech do jednej liczby — całkowicie zastępując krok flatten + warstwa gęsta.
Oto nasza klasa MaxPool2D — zwróć uwagę, że nie ma metody update, bo nie ma czego uczyć:
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):
for col in range(0, w, s):
out[c, r//s, col//s] = np.max(x[c, r:r+s, col:col+s])
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):
for col in range(0, w, 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 parametersSpłaszczenie i klasyfikacja
Zauważ, że mapy cech pozostają osobne przez całą konwolucję i pooling — każda jest zmniejszana niezależnie, nigdy nie scalana. Warstwy konwolucyjne wyodrębniają cechy, warstwy poolingu je kompresują, ale na końcu wciąż mamy objętość 3D (14×14×2 w naszym modelu). Warstwa gęsta nie umie z tym pracować — potrzebuje płaskiego wektora 1D.
To właśnie robi warstwa Flatten: bierze każdą wartość z każdego kanału i ustawia je w jeden długi wektor — wszystkie 14×14 = 196 wartości z kanału 1, potem wszystkie 196 z kanału 2, co daje jeden wektor 392-elementowy. Żadnych obliczeń, żadnego uczenia — tylko zmiana kształtu.
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ę.
To tutaj sieć podejmuje ostateczną decyzję. Warstwy konwolucyjne wykonały ciężką pracę — przekształciły surowe piksele w sensowne cechy w rodzaju „tu krawędź”, „tam róg”. Warstwa gęsta jedynie kreśli granicę decyzyjną w tej 392-wymiarowej przestrzeni cech, oddzielając wzorce kwadratopodobne od kołopodobnych.
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_bBudujemy 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 kształtów (2000 kwadratów + 2000 kół) o różnych rozmiarach i w różnych pozycjach:
SIZE = 28
LINE_WIDTH = 2.0
rr, cc = np.mgrid[0:SIZE, 0:SIZE] + 0.5 # grid of pixel centers
def make_square():
img = np.zeros((SIZE, SIZE), dtype=np.float32)
s = np.random.randint(8, 22) # random side length
r = np.random.randint(0, SIZE - s + 1) # random position
c = np.random.randint(0, SIZE - s + 1)
for t in range(int(LINE_WIDTH)): # draw 2px thick edges
img[r+t, c:c+s] = 255; img[r+s-1-t, c:c+s] = 255
img[r:r+s, c+t] = 255; img[r:r+s, c+s-1-t] = 255
return img
def make_circle():
img = np.zeros((SIZE, SIZE), dtype=np.float32)
radius = np.random.uniform(4.0, 10.0)
margin = radius + 2.0
cy = np.random.uniform(margin, SIZE - margin)
cx = np.random.uniform(margin, SIZE - margin)
dist = np.sqrt((rr - cy)**2 + (cc - cx)**2) # vectorized distance
ring_dist = np.abs(dist - radius)
img[ring_dist <= LINE_WIDTH / 2] = 255 # solid ring
aa = (ring_dist > LINE_WIDTH / 2) & (ring_dist <= LINE_WIDTH / 2 + 0.5)
img[aa] = 255 * (LINE_WIDTH / 2 + 0.5 - ring_dist[aa]) / 0.5 # anti-aliasing
return img
N = 2000
squares = np.array([make_square() for _ in range(N)])
circles = np.array([make_circle() for _ in range(N)])Dlaczego np.mgrid przyspiesza generowanie kół
Zauważ, że make_circle używa np.mgrid zamiast zagnieżdżonej pętli Pythona po wszystkich 28×28 pikselach. np.mgrid tworzy siatkę wszystkich współrzędnych pikseli naraz, a potem NumPy liczy odległość od środka dla wszystkich 784 pikseli jednym zwektoryzowanym wywołaniem — bez żadnej pętli w Pythonie. Przy 2000 kół to różnica między sekundami a minutami.
Następnie normalizujemy i dzielimy:
X = np.concatenate([squares, circles]) / 255.0 # normalize to [0, 1]
y = np.concatenate([np.zeros(N), np.ones(N)]) # 0 = square, 1 = circle
X_train, X_test = X[:3200], X[3200:]
y_train, y_test = y[:3200], y[3200:]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):
return 1 / (1 + np.exp(-x))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):
# label is 0 (square) or 1 (circle)
# predicted is the sigmoid output (probability of circle)
return -label * np.log(predicted) - (1 - label) * np.log(1 - predicted)Mierzy ona, jak daleko przewidziane prawdopodobieństwo jest od prawdziwej etykiety: jeśli model mówi 0,95 dla koła i to faktycznie koło, strata jest mała; jeśli mówi 0,3 — strata jest duża.
Pętla treningowa
Pętla treningowa realizuje ten sam czterokrokowy proces co w poprzednich artykułach — przejście w przód, strata, propagacja wsteczna, spadek gradientu. Przejście w przód spina wszystkie nasze warstwy:
def forward(image):
activated = conv.forward(image) # 28×28 → 28×28×2 (conv + ReLU)
pooled = pool.forward(activated) # 28×28×2 → 14×14×2
flat = pooled.reshape(-1) # 14×14×2 → 392
output = sigmoid(dense.forward(flat)) # 392 → 1
return outputPrzejś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 = output - label # sigmoid + BCE gradient
grad = dense.backward(grad) # dense layer
grad = grad.reshape(14, 14, 2) # un-flatten
grad = pool.backward(grad) # pooling: route to max positions
conv.backward(grad) # conv: ReLU + accumulate across positionsGdy przejście w przód i wstecz są zdefiniowane, pętla treningowa jest prosta:
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)Wyniki treningu
Trenowaliśmy na 4000 wygenerowanych kształtów (2000 kwadratów + 2000 kół). Oto jak trening postępuje przez 20 epok — strata spada, a trafność rośnie zarówno na zbiorze treningowym, jak i walidacyjnym:
Model osiąga 100% trafności na teście — a wyuczone filtry to dokładnie te, które widziałeś w demie map cech powyżej.
Dla porównania oto odpowiednik w Kerasie — cały model i pętla treningowa z góry zwijają się do kilku linijek:
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, y_train, epochs=20, batch_size=32, validation_split=0.1)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 359 — 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:
Ta CNN osiąga na MNIST ~99% trafności na teście w 5 epok — wyraźna poprawa względem ~97%, które dostaliśmy na samych warstwach gęstych.