У попередніх статтях ми навчив нейронну мережу на MNIST використовували щільні шари — де кожен нейрон одного шару з’єднується з кожним нейроном наступного, з оновленими ваговими коефіцієнтами через зворотне поширення. Щоб працювати з щільними шарами, нам довелося звести кожне зображення розміром 28 × 28 у 784-елементний вектор і подати його в шар із 128 нейронів. Тільки цей перший шар мав 784×128=100,352784 \times 128 = 100{,}352 ваги + 128128 зміщення = 100,480100{,}480 параметри — кожен вхідний піксель з’єднується з кожним нейроном. Це спрацювало — ми досягли 97% точності, — але цей підхід має дві основні проблеми:

1. Відсутність вбудованої просторової структури. Зведення зберігає кожен піксель і його порядок; ми можемо змінити форму вектора назад у зображення. Але щільний шар не кодує, які пікселі є сусідами, і не використовує детектор у різних позиціях. Зміщений шаблон має різну вагу, тому вивчення його в одному місці не навчить мережу автоматично розпізнавати його в іншому місці.

2. Забагато параметрів. Із зображенням 28 × 28 можна керувати 100 тис. параметрів. Але реальні зображення набагато більші. Зображення 224 × 224 RGB (звичайний розмір введення) має значення 224×224224 \times 224 пікселів × 3\times\ 3 каналів кольору =150,528= 150{,}528. Один щільний шар із 512 нейронами потребує 150,528×512≈77150{,}528 \times 512 \approx 77 мільйонів параметрів — лише для одного шару. Тренувати це дорого, і його легко перевантажити.

How can parameter count affect overfitting?
Більше параметрів дає моделі більше можливостей для відповідності навчальним даним, включаючи шум. Чи буде він узагальнений, також залежить від даних, регулярізації, архітектури та оптимізації. Наявність більшої кількості параметрів, ніж прикладів, сама по собі не означає переобладнання.

Згорткові нейронні мережі (CNN) вирішують обидві проблеми одночасно. Замість того, щоб дивитися на всі пікселі одночасно, CNN зосереджується на невеликих локальних регіонах — наприклад, на патчі 3×3 — і вчиться розпізнавати шаблони в них. Фундаментальна відмінність: щільні шари вивчають глобальні моделі, що включають усі пікселі одночасно, тоді як згорткові шари вивчають локальні моделі — і повторно використовують той самий детектор у кожній позиції на зображенні.

Спробуйте відрізнити квадрати від кіл на сітці 28×28. Локальні патчі містять корисні підказки: квадрат має гострі кути та прямі сторони, а коло має вигнуті краї. Діаграма ілюструє характеристики кандидатів; навчання визначає, які відповіді модель фактично використовує.

28 × 28

Використання CNN дозволяє нам безпосередньо вирішувати кожну проблему:

Convolution забезпечує локальне підключення та розподіл ваги. Фільтр 3×3 для одного вхідного каналу має 9 ваг плюс зміщення. У кожній позиції застосовуються однакові ваги, тому шар може реагувати на вивчений шаблон, де б він не з’являвся.

CNN використовуються для класифікації зображень, виявлення об’єктів і розпізнавання тексту. Ця ж операція ковзного фільтра також поширюється на одновимірні сигнали, такі як аудіо, і тривимірні входи, такі як гучність.

Згортка в нейронних мережах

Наш CNN поєднує в собі згортку, активацію ReLU, об’єднання та щільний класифікатор. Згортка обчислює локальні відповіді; ReLU робить перетворення нелінійним; об’єднання зменшує просторові розміри. Ось архітектура в нотації Keras:

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

Згортка виявляє шаблони, об’єднання стискає результат, а щільний шар робить остаточну класифікацію. Ось як виглядає повний конвеєр CNN — від пікселів до прогнозів:

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

Шари згортки та об’єднання створюють функції, які щільний шар об’єднує в прогноз. Навчання налаштовує екстрактор ознак і класифікатор разом.

Крок згортки виявляє локальні шаблони — він розміщує вивчені фільтри по зображенню та створює карти функцій, які підкреслюють, де з’являється кожен шаблон. Він нічого не класифікує — він перетворює необроблену піксельну сітку на більш багате представлення. Це вилучення функцій, така сама ідея, як розробка функцій у традиційному ML. Замість того, щоб подавати необроблені дані в класифікатор, ви спочатку перетворюєте їх у більш корисні представлення. Різниця полягає в тому, що CNN вивчають, які функції видобувати автоматично — значення ядра виявляються під час навчання, а не розроблені вручну.

Крок об’єднання просторово стискає кожну карту функцій — вона зменшує ширину та висоту, зберігаючи найсильніші сигнали. Це зменшує кількість значень, які має обробити щільний шар (з 28 × 28 × 2 = 1568 до 14 × 14 × 2 = 392 у нашій моделі), і робить функції більш стійкими до невеликих змін у положенні.

Ми запровадимо ці шари в NumPy та перевіримо фільтри, вивчені під час невеликого завдання класифікації форм.

Згортка як трансформація зображення

Згортка — це не винахід ML — це фундаментальна техніка обробки сигналів і зображень, яка існувала задовго до глибокого навчання. Якщо ви коли-небудь застосовували розмиття, різкість або фільтр виявлення країв у програмному забезпеченні для редагування зображень — це згортка. Під капотом кожен фільтр — це невелика сітка чисел (називається ядром), яка ковзає по зображенню. У кожній позиції кожне значення ядра множиться на піксель, що перекривається, добутки підсумовуються, а результат записується у вивід.

Перемикайтеся між наведеними нижче фільтрами та порівняйте значення ядра 3×3 із виводом праворуч:

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

Зверніть увагу, як детектор горизонтальних країв підсвічує верхній і нижній краї фігури — це досягається за рахунок наявності від’ємних значень у верхньому рядку та додатних у нижньому рядку ядра, тому він реагує там, де яскравість змінюється вертикально. Фільтр різкості має велике додатне центральне значення та негативні сусіди — він посилює різницю між пікселем та його оточенням, роблячи краї чіткішими.

Це класичні, добре відомі ядра обробки зображень — розроблені вручну за десятиліття до того, як існувало глибоке навчання. У наведеному нижче описі пояснюється, що обчислює кожне ядро ​​та чому результат виглядає саме так.

фільтрЯк це працює
РозмиттяУсереднює всі 9 пікселів однаково — згладжує відмінності. Однорідна область залишається незмінною, але різкі переходи розбавляються
РізкістьПосилює центральний піксель відносно його сусідів. На гладких ділянках сусіди компенсуються; на краях невідповідність стає перебільшеною
Горизонтальні країВіднімає верхні пікселі від нижніх — якщо вони схожі, результат майже нульовий. Великий вихід лише там, де яскравість змінюється вертикально, тобто горизонтальний край
Вертикальні країТа сама ідея повернута: віднімання ліворуч від права. Результат є сильним лише там, де яскравість змінюється горизонтально, тобто вертикальний край

Та сама операція ковзання, той самий вхід — різні результати. 9 чисел у ядрі повністю визначають, як виглядає результат. Для чудового візуального пояснення того, як працюють згортки, див. це відео від 3Blue1Brown.

Згортка як відповідність шаблону

Розсувне вікно також можна використовувати для підбору шаблону. Виберіть невеликий фрагмент зображення та порівняйте його з фрагментами в інших місцях. Наступна демонстрація нормалізує ці порівняння, щоб зосередитися на шаблоні, а не на його загальній яскравості.

Клацніть будь-яку частину фігури нижче, щоб вирізати патч 3×3 і використовувати його як ядро. Наприклад, клацніть вертикальний край квадрата, щоб побачити, як він висвітлює всі вертикальні краї, або клацніть округлення кола, щоб побачити, де з’являються подібні криві. Віджет ковзає ядром по всьому зображенню, і результат підсвічується там, де знайдено схожі шаблони. Ви також можете натиснути Грати, щоб переглянути механіку ковзання крок за кроком:

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

Ця демонстрація використовує косинусну подібність: скалярний добуток, поділений на довжину векторів ядра та патча. Він визначає оцінку як нуль, коли будь-який вектор дорівнює нулю, і затемнює оцінки нижче 0,85 для відображення. Згортка CNN не виконує цю нормалізацію: вона обчислює зважену суму плюс зсув. Демонстрація ілюструє пошук за допомогою ковзного патча, але його результати не є результатами згортки.

Від створених вручну до навчених фільтрів

Обидва способи використання згортки — трансформація зображення та зіставлення шаблону — покладаються на правильно вибрані значення ядра. Обробка зображень десятиліттями використовувала розроблені вручну ядра (фільтри розмиття, різкості та фільтри Sobel у демонстрації вище). Вони корисні, але вибирати їх вручну втомливо й обмежено.

Основна ідея CNN: замість того, щоб проектувати ці значення вручну, дозвольте мережі автоматично вивчати їх для поточного завдання. Фільтр починається з випадкових чисел і оновлюється градієнтним спуском під час навчання — так само, як вивчаються ваги в щільних шарах. Мережа виявляє, які шаблони важливі для завдання. Для нашого завдання «квадрат проти кола» він може вивчати фільтри, які по-різному реагують на прямі та вигнуті краї — незалежно від того, що допомагає розділити два класи.

Операція згортки

Вище ми бачили, як згортки можуть допомогти з трансформацією зображення та зіставленням шаблонів. Тепер давайте подивимося на фактичну механіку: що саме відбувається в кожній позиції, і як створюється вихід — так звана карта функцій — будується?

Чому "карта об'єктів"?

У назві слово «функція» використовується в тому ж сенсі, що й у ML загалом — вимірювана властивість, корисна для завдання. Подібно до того, як таблична модель може використовувати такі функції, як «вік» або «дохід», функція CNN — це візуальний шаблон, наприклад «вертикальний край» або «крива». Карта функцій — це просторова сітка, яка показує, де ця функція була виявлена ​​на зображенні — високі значення там, де шаблон присутній, низькі там, де його немає.

Основна операція проста: візьміть невелику матрицю (ядро), проведіть нею по вхідних даних і в кожній позиції обчисліть скалярний добуток — помножте кожне значення ядра на піксель, що перекривається, підсумуйте добутки — і запишіть результат у карту функцій. Ядро може бути будь-якого розміру (1 × 1, 5 × 5, 7 × 7) — старіші мережі, такі як AlexNet, використовували ядра 11 × 11, але 3 × 3 є найпоширенішим вибором у сучасних архітектурах, і ми будемо використовувати його в цій статті.

Давайте подивимось на практиці. Скажімо, ви клацаєте лівий край квадрата в демонстрації вище — ядро ​​стає патчем 3×3 із чорним (0) фоном і білим (255) там, де лінія краю. Коли це ядро пересувається до іншого лівого краю, фрагмент зображення виглядає так само — кожна позиція збігається, тому продукти великі:

Ядро лівого краю × пляма лівого краю — сильний збіг
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

Тепер давайте застосуємо те саме ядро до верхнього краю. Білі пікселі знаходяться в різних позиціях — білий стовпчик ядра здебільшого перекривається чорними пікселями:

Ядро лівого краю × пляма верхнього краю — слабкий збіг
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

Великий скалярний добуток може походити від вирівняних значень, великих величин або обох. Це не нормалізована міра подібності: повністю біла пляма отримує той самий скалярний добуток, що й відповідний край, оскільки пікселі з нульовою вагою ядра не впливають на суму. Навчені фільтри також можуть використовувати негативні ваги, щоб покарати небажану структуру.

Ця ж математика також пояснює розмиття. Кожне значення ядра розмиття має значення ⅑, тому зважена сума є лише середнім значенням 9 пікселів. На лівому краю нашивки:

Ядро розмиття (1/9 кожне) × пляма лівого краю
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

У цій одній позиції вихідний піксель становить ~85 — сіре значення між чорним (0) і білим (255). Ядро продовжує рухатися, обробляючи зображення позицію за позицією, рядок за рядком. Кожна позиція біля краю отримує однакове змішане значення, пом’якшуючи різкий стрибок від 0 до 255 у поступовий перехід. На плоскій ділянці (всі чорні або всі білі) кожен піксель у патчі є однаковим, тому середнє значення дорівнює вихідному — там нічого не змінюється.

Вихідний розмір

Коли фільтр ковзає по входу без будь-яких відступів, вихід стає меншим за вхід — це називається ефектом межі. Розглянемо вхід 5×5 із ядром 3×3. Ядро починається у верхньому лівому куті, охоплюючи рядки 0-2 і стовпці 0-2. Він ковзає праворуч на один піксель за раз, але він може переходити лише до стовпця 0, 1 або 2. Починаючи зі стовпця 3, ядро ​​охопить стовпці 3-5, а стовпець 5 не існує. Так само по вертикалі. Таким чином, існує лише 3 дійсні позиції на вісь, що створює вихід 3×3 із входу 5×5:

Для одного виміру, із вхідним розміром nn, розміром ядра kk, заповненням pp з кожного боку, кроком ss і без розширення, вихідний розмір становить ⌊(n+2p−k)/s⌋+1\lfloor (n + 2p - k)/s \rfloor + 1. Тут p=0p=0 і s=1s=1, що дає 5−3+1=35-3+1=3.

Доповнення

Щоб усунути ефект межі, ви можете додати рядки та стовпці з нулями навколо введення — це називається заповненням. Для ядра 3 × 3 ви додаєте 1 піксель нулів з кожного боку, тому вхід 28 × 28 стає доповненим входом 30 × 30, а вихід повертається до 28 × 28. Для ядра 5×5 потрібно додати по 2 пікселі з кожного боку.

Ми додаємо нулі, а не 1, тому що нуль разів будь-яке значення ядра дорівнює нулю — доповнені пікселі не впливають на скалярний добуток. На результат впливають лише справжні пікселі, які перекриваються ядром. Це все одно, що сказати «за межами зображення немає нічого».

Крок

Поки що ми припускали, що ядро рухається на один піксель за раз — крок 1. Але відстань між послідовними позиціями ядра є параметром, який ви можете змінити. Наприклад, з кроком 2 ядро ​​стрибає на 2 пікселі за раз, пропускаючи кожну іншу позицію — як горизонтально, так і вертикально. Більший крок означає менше позицій, що означає менший результат.

З кроком 2 ядро переміщує два пікселі за раз. У цьому прикладі 5 × 5 із ядром 3 × 3 і без доповнення результат стає 2 × 2 замість 3 × 3.

Зменшення дискретизації зменшує кількість активацій і обчислень на наступних рівнях. Це також зменшує кількість параметрів наступного сплощеного щільного шару. Для пізнішої згортки з фіксованим розміром ядра та кількістю каналів кількість параметрів залишається незмінною.

Наша модель знижує дискретизацію за допомогою max pooling, окремої операції, яка бере максимум у кожному локальному вікні. Ми реалізуємо це нижче.

Всередині згорткового шару

Досі ми розглядали згортку як окрему операцію — одне ядро ковзає по зображенню, створюючи одну карту функцій. Тепер давайте подивимося, як ця операція упакована в рівень, який мережа може навчити. У Keras згортковий шар визначається так:

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

Вивчені параметри - це ваги ядра та зміщення. Карти функцій – це активації, обчислені для кожного входу, а не вивчені параметри. Під час навчання реалізація також кешує вхідні дані та активації, необхідні для зворотного поширення.

І щільні, і згорткові шари приймають весь вхідний сигнал, але обробляють його по-різному. Візьмемо спрощену сітку 3×3 із ядром 2×2. Щільний нейрон має унікальну вагу на піксель (9 вагових коефіцієнтів для нашої простої сітки 3x3 або 784 вагові коефіцієнти для зображення 28×28), обчислює одну зважену суму та створює одне значення.

Ядро conv має лише кілька спільних ваг, але ковзає по кожній позиції зображення, створюючи одне значення на позицію — цілу карту функцій. У кожній позиції рівень витягує локальний патч із вхідних даних, множить його поелементно на ядро, підсумовує всі результати та додає зміщення.

Щільний нейрон бачить усі 9 пікселів одночасно з 9 унікальними вагами, створюючи один вихід. Ядро conv бачить 4 пікселі за раз (2×2), але відвідує кожну позицію — однакові 4 ваги, які використовуються всюди. Менше параметрів, але все одно охоплено повне зображення.

Є корисний спосіб прочитати цю ковзаючу картинку. Кожна позиція, яку відвідує ядро, створює одне вихідне значення — і ви можете розглядати кожну позицію як свій власний нейрон. Ядро у верхньому лівому куті – це один нейрон, дивлячись лише на верхній лівий ділянку; ядро на один крок праворуч — інший нейрон, який дивиться на ділянку, зміщену на один піксель; і так далі. Для входу 3 × 3 і ядра 2 × 2 вище, ядро ​​приземляється в 4 позиціях, тож цей крихітний шар фактично є 4 нейронами, а їхні виходи збираються в карту функцій 2 × 2. Збільшуйте масштаб, і кількість зростає разом із зображенням: вхід 28×28 із ядром 3×3 стає шаром із 26×26 = 676 нейронів.

Маленька ділянка, на яку дивиться кожен нейрон, має назву: її рецептивне поле — власна область інтересу у вхідних даних. Сприйнятливим полем верхнього лівого нейрона є верхній лівий кут 2×2, і він реагує лише на ці пікселі, повністю ігноруючи решту зображення. Кожен нейрон має різне сприйнятливе поле, і разом вони утворюють весь вхідний сигнал.

Щільний нейрон має власні ваги для всього входу. Вихід згортки використовує локальне сприйнятливе поле та має спільні ваги з іншими позиціями в тому самому вихідному каналі. Чотири позиції вище є тим самим фільтром, оціненим у чотирьох місцях; інші вихідні канали використовують власні фільтри.

Розподіл ваги робить згортку кроку 1 еквіваріантною перекладу, уникаючи граничних ефектів: зміщення вхідних даних зміщує карту функцій. Це не робить кінцевий класифікатор інваріантним. Об’єднання може зменшити чутливість до деяких невеликих змін, але перетин межі вікна об’єднання може змінити результат.

І як тільки шари складаються, сприйнятливе поле нейрона зростає — нейрон другого рівня зчитує ділянку вихідних даних першого рівня, кожен з яких уже узагальнив ділянку пікселів, тому він опосередковано бачить ширшу область вихідного зображення. Це «ширше поле зору», до якого ми повернемося з кроком і глибиною.

Ось реалізація NumPy для одного каналу введення, крок 1 і ядер непарного розміру. Він зберігає вихідні дані як (channels, height, width). Keras за замовчуванням використовує (height, width, channels). Почніть з 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)

Зважена сума в кожній позиції плюс зсув — ось і вся операція. Зміщення (одне на кожне ядро) зміщує вихідні дані вгору або вниз, даючи ядру поріг для того, наскільки сильно шаблон повинен відповідати, перш ніж він активується. Працює точно так само, як ухил у щільному шарі.

Як навчаються ядра: зворотне поширення в CNN

Зворотне поширення обчислює градієнти; оптимізатор використовує їх для оновлення ваг. Для одного зразка щільна вага сприяє одному обчисленню виходу. Вага згортки повторно використовується в кожній просторовій позиції, тому її градієнт підсумовує внески з усіх цих позицій. Обидва шари також накопичують градієнти між зразками в міні-пакеті.

# 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)
)

Тут gradient[r, c] є похідною від втрати щодо попередньої активації в цій вихідній позиції після застосування похідної ReLU. Кожен член є вхідним значенням, помноженим на відповідний вихідний градієнт.

Ось як це накопичення виглядає в нашому класі 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

Градієнт зсуву є сумою grad за просторовими позиціями. Ця реалізація першого рівня обчислює лише градієнти параметрів; укладання шарів згортки, які можна навчати, також вимагатиме повернення градієнта щодо вхідних даних.

Кілька ядер, кілька карт функцій

Один фільтр створює одну карту ознак: карту відповідей на її ваги. Різні фільтри можуть реагувати на різні структури, хоча фільтр не обов’язково повинен відповідати одному чітко названому шаблону.

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

Це вимагає використання декількох ядер на шар для створення багатьох карт функцій — по одній на ядро, кожна з яких виявляє різний шаблон. Отже, кожен згортковий шар створює стільки карт функцій, скільки ядер у нього — 2 у нашому випадку, але це навмисно мінімально.

Використання більшої кількості фільтрів збільшує кількість вихідних каналів і пропускну здатність моделі. Навчання може створювати додаткові фільтри, але воно також може створювати надлишкові або неактивні.

Ось що ми отримали після навчання нашого 2-фільтрового CNN — значення ядра, на яких він зійшовся, створюють помітно різні карти функцій для квадратів і кіл. Це карти функцій із нашого єдиного згорткового шару — у глибшій мережі кожен шар створюватиме власний набір карт функцій, а глибші шари фіксуватимуть дедалі складніші моделі:

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

Ці карти використовують вивчені вагові коефіцієнти та зміщення з тренувального запуску нижче, із застосуванням вхідних пікселів, нормалізованих до [0, 1] і ReLU. Яскравість кожної карти масштабується незалежно, тому порівнюйте просторові моделі, а не абсолютну яскравість між картами.

Щільний шар отримує об’єднані відповіді з обох карт. Ці відповіді можуть допомогти розділити два класи, не будучи спеціальним «детектором квадратів» або «детектором кіл».

Укладання шарів: від країв до форм

Для цього створеного набору даних достатньо одного згорткового шару з двома фільтрами. Накопичування шарів дозволяє пізнішим фільтрам поєднувати попередні відповіді через більші рецептивні поля, що може допомогти з більш різноманітними зображеннями.

Справжня потужність CNN походить від укладання згорткових шарів. Щоб зрозуміти чому, подумайте про те, як розкласти проблему розпізнавання вручну. Якщо хтось попросить вас виявити цифру «0», ви можете розбити це на підпроблеми: чи є верхня крива? Нижня крива? Лівий край? Правий край? Кожна підмережа відповідає на одне запитання, а останній рівень поєднує їхні результати:

Кожну з цих підпроблем можна розкласти далі. “Верхня крива?” розбивається на: чи є верхня ліва дуга? Верхня права дуга? Горизонтальний край на піку? Кожне запитання стає простішим, ближчим до сирих візуальних функцій:

Ребра → криві → частини є корисною ілюстрацією збільшення сприйнятливих полів, а не гарантованим описом того, що вивчає кожен шар. Фактичні функції залежать від даних, архітектури та навчання.

І на кожному рівні кілька ядер працюють паралельно — одне ядро може навчитися виявляти горизонтальні грані, а інше — вертикальні, одне може знаходити прямі кути, а інше — плавні криві. Ось чому кожен рівень має багато ядер — мережі потрібно відстежувати багато різних шаблонів одночасно на кожному етапі ієрархії.

Кілька вхідних каналів

Все, що описано вище, описує перший згортковий рівень в ієрархії, де кожне ядро отримує одне двовимірне зображення як вхідні дані. Але більш глибокі шари в стеку працюють з чимось іншим. Перший згортковий шар приймає зображення у відтінках сірого — єдину двовимірну сітку 28×28. Але вхідні дані другого згорткового шару більше не є плоским 2D-зображенням — це вихідні дані першого шару, стек карт 2D-об’єктів, що робить його 3D-об’ємом (28×28×2 у нашому випадку або 28×28×32 у типовій мережі):

Кожна вихідна карта функцій стає вхідним каналом для наступного шару. Канали є паралельними компонентами в однакових просторових розташуваннях. Їх порядок повинен відповідати вагам наступного шару; зміна каналів без заміни відповідних ваг змінює обчислення.

Оскільки вхідні дані тепер мають кілька складених карт функцій (кілька каналів), ядро має обробити їх усі. Ядро автоматично відображає вхідну структуру — її глибина (кількість фрагментів) визначається відповідно до кількості вхідних каналів. Кожен рівень все ще може — і зазвичай має — мати кілька ядер, але тепер кожне ядро ​​стає тривимірним — його глибина автоматично відповідає кількості вхідних каналів.

З 2 вхідними каналами кожне ядро стає стеком з двох матриць 2×2, по одній на канал. У кожній позиції перша матриця множиться на канал 1, друга – на канал 2, і всі продукти підсумовуються в одне вихідне значення на позицію. Переміщення цього 3D-ядра по всіх позиціях створює лише одну карту функцій як результат одного ядра — одне значення на кожну позицію.

На діаграмі нижче показано дві позиції, щоб проілюструвати, як те саме ядро створює різні значення, які всі вносять свій внесок у одну карту функцій:

Операція — це та сама зважена сума, яку ми вже знаємо — просто запустіть один раз для кожного каналу, а потім підсумуйте результати. На діаграмі показано ядро ​​2×2, що ковзає по входу 3×3 із 2 каналами. Для кожної позиції ми обчислюємо зважену суму для кожного каналу окремо, а потім додаємо результати:

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

Для звичайної згортки кожен вихідний фільтр охоплює всі вхідні канали. Його кількість параметрів kernel_height × kernel_width × input_channels + 1 для зміщення. Одна лише глибина не робить ядро ​​більшим; зміна кількості вхідних каналів робить.

Об’єднання

Після того, як згортковий шар виявляє особливості, ми хочемо зменшити кожну карту функцій — як зменшення масштабу зображення, але замість усереднення пікселів ми зберігаємо лише найсильніші сигнали. Кожна карта функцій зменшується незалежно — об’єднання каналів не об’єднує. Глибина залишається незмінною: 28×28×2 стає 14×14×2, а не 14×14×1.

Об’єднання не має вивчених ваг. Він узагальнює локальне вікно незалежно в кожному каналі. Максимальне об’єднання зберігає найбільше значення; він може допускати деякі зрушення у вікні, але загалом не є інваріантним до перекладу.

Існують різні стратегії об’єднання — максимальне, середнє та інші, — але найпоширенішою є максимальне об’єднання: візьміть вікно 2×2 і перемістіть його по карті об’єктів кроком 2 — оскільки крок відповідає розміру вікна, кожне вікно охоплює нову область без перекриття. У кожній позиції зберігайте лише максимальне значення.

Об’єднання може звучати як поступове згортання — одночасно зсувайте вікно та зменшуйте дискретизацію. Але вони відрізняються двома важливими моментами. По-перше, нічого не вивчається — об’єднання не має ваг, ядра чи параметрів, які оновлюються під час навчання. По-друге, сама операція інша — замість зваженої суми (множення та підсумовування) об’єднання просто вибирає макс.

Для нашої моделі об’єднання зменшує карту функцій 28×28 до 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

Якщо відповідь рухається в межах того самого вікна об’єднання та залишається максимальним, це об’єднане значення залишається незмінним. Перемістіть його через межі вікна, і вихід може змінитися. Об’єднання пропонує обмежений допуск до зсуву, але не гарантує, що зсув на один піксель залишить прогнози незмінними.

Середнє об’єднання бере середнє значення кожного вікна замість максимального. Глобальне середнє об’єднання усереднює всю карту функцій в одне значення на канал. Потім класифікатор може використовувати ці значення, часто з кінцевим щільним шаром, замість зведення кожної просторової позиції.

Наш MaxPool2D використовує вікна, що не перекриваються, і відкидає кінцеві рядки або стовпці, які не утворюють повне вікно. Під час зворотного поширення кожен градієнт досягає максимуму вікна; зв’язки йдуть до першого максимуму. Немає параметрів для оновлення:

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

Звести та класифікувати

Пул обробляє канали незалежно; згортка може їх поєднувати. Для цього класифікатора ми зводимо всі об’єднані активації в один вектор, щоб щільний вихід з’єднувався з кожною просторовою позицією в кожному каналі. Keras Dense може приймати вхідні дані вищого рангу, але тоді він працює на останній осі, а не автоматично вирівнює вхідні дані.

Наші масиви NumPy є каналами першими: зведення зчитує 196 значень каналу 1, потім 196 значень каналу 2. Keras за замовчуванням використовує канали останніх, тому його зведене впорядкування відрізняється. Будь-яка конвенція працює, коли для проходу вперед і назад використовується однаковий макет.

Це те саме змінення форми, яке ми робили в щільна модель MNIST, коли ми зводили 28×28 у 784. Сам Flatten не знає, що саме він змінює, — операція ідентична. Різниця полягає в тому, що робили попередні шари: у щільному підході не було попередніх шарів, тому Flatten отримував необроблені пікселі. Тут згортка та об’єднання вже витягли та стисли просторові візерунки, тому щільний шар отримує важливі функції, а не необроблені значення пікселів.

Тоді щільний шар діє як класифікатор. Оскільки ми маємо лише два класи (квадрат і коло), це бінарна класифікація — нам потрібен лише один нейрон із сигмоподібною активацією. Він підключається до всіх 392 значень, множить на вивчені ваги, підсумовує, додає зміщення та виводить одне число від 0 до 1: ймовірність того, що вхідні дані є колом. Близько до 0 = квадрат, близько до 1 = коло. Для задач із кількома класами, як-от MNIST (10 цифр), замість цього слід використовувати 10 нейронів із softmax — по одному на клас.

Ось клас DenseLayer — така сама структура, як і HiddenLayer з попередньої статті:

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

Побудова моделі CNN для фігур

Тепер, коли ми розглянули кожен компонент, давайте побудуємо модель і навчальний цикл — так само, як ми робили для щільна модель MNIST, але тепер зі згортковими шарами.

Ми створюємо екземпляри шарів, які ми створили в цій статті:

# 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)

Підготовка даних

Ми створюємо 4000 зображень: 2000 квадратів і 2000 кіл. Обидва класи використовують той самий діапазон розмірів, рамку, товщину лінії та правило згладжування країв, тому відмінності у візуалізації менш імовірно створять ярлики. Це залишається свідомо простим синтетичним завданням.

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)])
Why use np.mgrid?
np.mgrid створює масиви піксельних координат. make_shape потім обчислює відстані та покриття країв для всіх пікселів за допомогою операцій масиву NumPy, уникаючи циклу Python над окремими пікселями.

Нормалізуйте пікселі до [0, 1], а потім розділіть кожен клас окремо: 1440 зображень для навчання, 160 для перевірки та 400 для тестування. Перетасування всередині кожної підмножини дає збалансовані набори з 2880, 320 і 800 зображень. Відкладіть тест до завершення навчання.

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]

Зверніть увагу, що ми не зводимо зображення — на відміну від щільної моделі MNIST, де ми змінили форму до 784 векторів, для рівня conv потрібна недоторкана 2D-просторова структура.

Активація виходу

Наша модель виводить одне число — ймовірність того, що вхідні дані є колом. Щоб перетворити необроблений результат щільного шару (який може бути будь-яким значенням) у ймовірність від 0 до 1, ми використовуємо sigmoid:

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

Це та сама функція, яку ми використовували б у логістичній регресії — вона здавлює будь-яке значення в діапазон (0, 1). Для багатокласових проблем, таких як MNIST, ми б замість цього використовували softmax.

Функція втрат

Щоб визначити, наскільки помилковим є прогноз, ми використовуємо двійкову перехресну ентропію — ту саму ідею, що й крос-ентропійні втрати зі статті MNIST, але адаптовану для двох класів замість десяти:

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)

Для кола передбачення 0,95 дає менші втрати, ніж передбачення 0,3. Відсікання зберігає цю функцію звітування кінцевою при ймовірностях 0 і 1. У зворотному проході нижче використовується аналітичний градієнт sigmoid-plus-BCE p - y перед відсіканням; виробничі реалізації зазвичай обчислюють BCE безпосередньо з логітів для чисельної стабільності.

Навчальний цикл

Відтворіть модель після генерації та розділення даних, як у блокноті. У прямому проході використовуються масиви перших каналів:

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]

Зворотний прохід відображає це у зворотному напрямку — від градієнта втрат назад через щільний, незрівняний, об’єднання та конверсію. Ми детально зануримося в те, чому output - label є правильним початковим градієнтом для сигмоїдної + двійкової перехресної ентропії в наступній статті:

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

Ми накопичуємо градієнти для кожного зображення та усереднюємо їх перед кожним оновленням SGD. Блокнот також оцінює втрати перевірки після кожної епохи та оцінює набір тестів один раз наприкінці. Його векторизована згортка та об’єднання реалізують ті самі операції, що й цикли, показані тут.

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)
Завантажте робочий блокнот NumPy

Результати навчання

У наведеному нижче циклі використовується 2880 навчальних зображень і 320 перевірочних зображень для 20 епох. Метрики навчання накопичуються протягом кожної епохи; метрики перевірки використовують ваги наприкінці цієї епохи. Окремий тестовий набір із 800 зображень не використовується для оновлень або кривої перевірки.

Остаточна точність проведеного тесту вказана під таблицею. Завантажуваний блокнот експортує як ці показники, так і ваги, які використовуються в демонстраційній карті функцій. Результати цих згенерованих форм не встановлюють стійкість до інших стилів малювання чи реальних зображень.

Така сама архітектура в Keras використовує останній вхід каналів. У цій версії використовується Адам, а не SGD блокнота, тому траєкторія навчання буде іншою:

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))

Розширення: від форм до MNIST

Наш класифікатор форм має лише 413 параметрів, оскільки завдання просте. Класифікація реальних зображень потребує більшої потужності. Ось типовий CNN для розпізнавання цифр MNIST — ті самі принципи, тільки більше:

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
])

Відмінності від нашої іграшкової моделі:

  • Вхід 28×28 той самий розмір, але цифри в градаціях сірого написані від руки замість простих геометричних фігур
  • 32 і 64 фільтри замість 2 — набагато більше шаблонів для виявлення
  • Два conv+pool blocks замість одного — ієрархічне вилучення ознак
  • 10-клас softmax замість двійкової сигмоіди — 10 цифр для розрізнення
  • ~225 000 параметрів замість 413 — набагато більше ємності

Але будівельні блоки ідентичні: ковзайте невеликі фільтри для побудови карт об’єктів, пул для зменшення розміру, зведення, класифікація. Ось як відбувається навчання протягом 5 епох:

Збережений цикл MNIST досягає приблизно 99,08% точності перевірки після 5 епох. Ці нанесені на графік значення є показниками перевірки; вони не записують окрему підсумкову контрольну оцінку. Порівняння з більш ранньою щільною мережею вимагає оцінки обох моделей на одному і тому ж наборі тестів.

Перегляд зображення

Змінюйте масштаб жестом щипка або клавішами + / −. Переміщуйте зображення перетягуванням або стрілками. Esc — закрити.