Разбираемся в CNN — свёртки, карты признаков и пулинг

В предыдущих статьях мы обучили нейросеть на MNIST, используя полносвязные слои, где каждый нейрон одного слоя соединён с каждым нейроном следующего, а веса обновляются обратным распространением. Чтобы работать с полносвязными слоями, нам пришлось разворачивать каждое изображение 28×28 в вектор из 784 элементов и подавать его на слой из 128 нейронов. Один только первый слой имел 784×128=100,352784 \times 128 = 100{,}352 весов + 128128 смещений = 100,480100{,}480 параметров — каждый входной пиксель соединён с каждым нейроном. Это работало — мы дошли до 97% точности, — но у такого подхода две фундаментальные проблемы:

1. Нет встроенной пространственной структуры. Развёртка сохраняет все пиксели и их порядок: вектор можно преобразовать обратно в изображение. Но полносвязный слой не задаёт соседство пикселей и не использует один детектор во всех позициях. Сдвинутый узор попадает на другие веса, поэтому обучение на одной позиции не обеспечивает распознавание в остальных.

2. Слишком много параметров. Для изображения 28×28 сто тысяч параметров ещё терпимо. Но настоящие изображения намного больше. У RGB-картинки 224×224 (типичный размер входа) 224×224224 \times 224 пикселей × 3\times\ 3 цветовых канала =150,528= 150{,}528 значений. Один полносвязный слой из 512 нейронов потребовал бы 150,528×512≈77150{,}528 \times 512 \approx 77 миллионов параметров — и это только на один слой. Такое дорого обучать и легко переобучить.

Как число параметров влияет на переобучение?
Больше параметров позволяет точнее подгонять модель под обучающие данные, включая шум. Обобщение также зависит от данных, регуляризации, архитектуры и оптимизации. Превышение числа параметров над числом примеров само по себе не означает переобучение.

Свёрточные нейронные сети (CNN) решают обе проблемы разом. Вместо того чтобы смотреть на все пиксели сразу, CNN фокусируется на маленьких локальных областях — например, на фрагменте 3×3 — и учится распознавать паттерны внутри них. Принципиальная разница: полносвязные слои учат глобальные паттерны, задействующие все пиксели сразу, тогда как свёрточные слои учат локальные паттерны и переиспользуют один и тот же детектор в каждой позиции изображения.

Рассмотрим различение квадратов и окружностей на сетке 28×28. Локальные фрагменты содержат полезные признаки: у квадрата острые углы и прямые стороны, а у окружности криволинейные края. Диаграмма показывает возможные признаки; обучение определяет, какие отклики модель использует на самом деле.

28 × 28

Использование CNN позволяет напрямую снять обе проблемы:

Свёртка использует локальные связи и общие веса. Фильтр 3×3 для одного входного канала содержит 9 весов и смещение. Одни и те же веса применяются в каждой позиции, позволяя слою реагировать на выученный узор в разных местах.

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 пикселей поровну — сглаживает различия. Однородная область остаётся такой же, но резкие переходы размываются
РезкостьУсиливает центральный пиксель относительно соседей. В гладких областях соседи взаимно гасятся; на границах несовпадение преувеличивается
Горизонтальные границыВычитает верхние пиксели из нижних — если они похожи, результат близок к нулю. Большое значение выходит только там, где яркость меняется по вертикали, то есть на горизонтальной границе
Вертикальные границыТа же идея, повёрнутая: вычитает левое из правого. Выход силён только там, где яркость меняется по горизонтали, то есть на вертикальной границе

Та же скользящая операция, тот же вход — разные результаты. Девять чисел в ядре полностью определяют, как будет выглядеть выход. Отличное визуальное объяснение того, как работают свёртки, есть в этом видео от 3Blue1Brown.

Свёртка как поиск совпадений по образцу

Скользящее окно можно использовать и для поиска похожих узоров. Выберите небольшой фрагмент изображения и сравните его с фрагментами в других местах. Следующая демонстрация нормализует сравнения, чтобы выделить структуру, а не общую яркость.

Кликните по любой части фигуры ниже, чтобы вырезать фрагмент 3×3 и использовать его как ядро. Например, кликните по вертикальной грани квадрата, чтобы увидеть, как подсвечиваются все вертикальные границы, или по закруглению круга, чтобы увидеть, где встречаются похожие изгибы. Виджет проводит ядро по всему изображению, и выход загорается там, где найдены похожие паттерны. Можно также нажать Play, чтобы пошагово посмотреть механику скольжения:

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

Эта демонстрация использует косинусное сходство: скалярное произведение, делённое на длины векторов ядра и фрагмента. Если один из векторов нулевой, результат считается нулём; значения ниже 0,85 на изображении затемняются. Свёртка в CNN не выполняет такую нормализацию: она вычисляет взвешенную сумму со смещением. Демонстрация показывает поиск скользящим фрагментом, но её оценки не являются выходами свёртки.

От ручных фильтров к выученным

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

Ключевая идея 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

Большое скалярное произведение может объясняться совпадением значений, их большой величиной или обоими факторами. Это не нормированная мера сходства: полностью белый фрагмент здесь даёт тот же результат, что и совпадающий край, поскольку пиксели под нулевыми весами не влияют на сумму. Обучаемые фильтры могут также использовать отрицательные веса для подавления нежелательных структур.

Та же математика объясняет и размытие. У ядра размытия каждое значение равно ⅑, так что взвешенная сумма — это просто среднее девяти пикселей. На фрагменте с левой границей:

Ядро размытия (по 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 допустимых позиции, и из входа 5×5 получается выход 3×3:

Для одного измерения при размере входа 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.

Дополнение

Чтобы противодействовать краевому эффекту, можно добавить вокруг входа строки и столбцы нулей — это называется дополнением (padding). Для ядра 3×3 добавляют 1 пиксель нулей с каждой стороны, так что вход 28×28 становится дополненным входом 30×30, а выход снова 28×28. Для ядра 5×5 добавляли бы по 2 пикселя с каждой стороны.

Мы добавляем именно нули, а не, скажем, единицы, потому что ноль, умноженный на любое значение ядра, равен нулю — дополняющие пиксели ничего не вносят в скалярное произведение. На результат влияют только настоящие пиксели, перекрывающиеся с ядром. Это как сказать: «за границей изображения ничего нет».

Шаг свёртки

До сих пор мы предполагали, что ядро сдвигается на один пиксель за раз — шаг (stride) 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 весов для нашей простой сетки 3×3 или 784 веса для изображения 28×28), он вычисляет одну взвешенную сумму и выдаёт одно значение.

У свёрточного ядра всего несколько разделяемых весов, но оно скользит по каждой позиции изображения, выдавая по одному значению на позицию — целую карту признаков. В каждой позиции слой извлекает локальный фрагмент входа, поэлементно умножает его на ядро, складывает все произведения и добавляет смещение.

Полносвязный нейрон видит все 9 пикселей сразу с 9 уникальными весами и выдаёт один выход. Свёрточное ядро видит по 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, но это намеренный минимум.

Большее число фильтров увеличивает число выходных каналов и ёмкость модели. Обучение может дать взаимодополняющие фильтры, но также избыточные или неактивные.

Вот что получилось после обучения нашей CNN с двумя фильтрами — значения ядер, к которым она сошлась, дают заметно разные карты признаков для квадратов и кругов. Это карты признаков нашего единственного свёрточного слоя; в более глубокой сети каждый слой давал бы свой набор карт признаков, и более глубокие слои схватывали бы всё более сложные паттерны:

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

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

Полносвязный слой получает отклики обеих карт после пулинга. Они могут помочь разделить классы, даже если ни один фильтр не является специальным «детектором квадратов» или «детектором окружностей».

Стек слоёв: от границ к формам

Для этого сгенерированного набора достаточно одного свёрточного слоя с двумя фильтрами. Несколько слоёв позволяют поздним фильтрам объединять ранние отклики в более широких рецептивных полях, что может помочь на более разнообразных изображениях.

Настоящая сила CNN проявляется при укладке свёрточных слоёв в стек. Чтобы понять почему, подумайте, как вы разложили бы задачу распознавания вручную. Если бы вас попросили обнаружить цифру «0», вы могли бы разбить это на подзадачи: есть ли верхний изгиб? нижний изгиб? левая граница? правая граница? Каждая подсеть отвечает на один вопрос, а финальный слой объединяет их выходы:

Каждую из этих подзадач можно разложить дальше. «Верхний изгиб?» распадается на: есть ли дуга слева вверху? дуга справа вверху? горизонтальная граница на вершине? Каждый вопрос становится проще и ближе к сырым визуальным признакам:

Схема «края → кривые → части» иллюстрирует рост рецептивных полей, но не гарантирует, чему научится каждый слой. Реальные признаки зависят от данных, архитектуры и обучения.

И на каждом уровне параллельно работает множество ядер: одно может научиться обнаруживать горизонтальные границы, другое — вертикальные, одно находит прямые углы, другое — плавные изгибы. Вот почему в каждом слое много ядер: сети нужно отслеживать множество разных паттернов одновременно на каждой ступени иерархии.

Несколько входных каналов

Всё сказанное выше описывает первый свёрточный слой иерархии, где каждое ядро получает на вход одно двумерное изображение. Но более глубокие слои стека работают с чем-то другим. Первый свёрточный слой берёт изображение в градациях серого — одну двумерную сетку 28×28. А вход второго свёрточного слоя — уже не плоское 2D-изображение, это выход первого слоя, стопка двумерных карт признаков, то есть трёхмерный объём (28×28×2 в нашем случае или 28×28×32 в типичной сети):

Каждая выходная карта признаков становится входным каналом следующего слоя. Каналы — параллельные компоненты в одних и тех же пространственных позициях. Их порядок должен соответствовать весам следующего слоя: перестановка каналов без перестановки весов меняет вычисление.

Раз вход теперь содержит несколько сложенных карт признаков (несколько каналов), ядру нужно обработать их все. Ядро автоматически отражает структуру входа — его глубина (число срезов) выводится так, чтобы совпасть с числом входных каналов. У каждого слоя по-прежнему может быть — и обычно бывает — несколько ядер, но теперь каждое ядро становится трёхмерным: его глубина автоматически совпадает с числом входных каналов.

При 2 входных каналах каждое ядро становится стопкой из двух матриц 2×2, по одной на канал. В каждой позиции первая матрица умножается на канал 1, вторая — на канал 2, и все произведения складываются в одно-единственное выходное значение на позицию. Скольжение такого трёхмерного ядра по всем позициям даёт всего одну карту признаков как выход одного ядра — по одному значению на позицию.

Диаграмма ниже показывает две позиции, чтобы проиллюстрировать, как одно и то же ядро выдаёт разные значения, которые все вносят вклад в эту одну карту признаков:

Операция — та же взвешенная сумма, что мы уже знаем, просто выполненная по одному разу на канал, с последующим сложением результатов. На диаграмме ядро 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.

У пулинга нет обучаемых весов. Он обобщает локальное окно независимо в каждом канале. Max pooling сохраняет максимальное значение и может быть устойчив к некоторым сдвигам внутри окна, но в общем случае не инвариантен к сдвигам.

Есть разные стратегии пулинга — максимум, среднее и другие, — но самая распространённая это max pooling: берём окно 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

Если отклик сдвигается внутри того же окна и остаётся его максимумом, значение после пулинга не меняется. При пересечении границы окна выход может измениться. Пулинг даёт ограниченную устойчивость к сдвигам, а не гарантию неизменности предсказания при сдвиге на один пиксель.

Average pooling вычисляет среднее в каждом окне вместо максимума. Global average pooling усредняет всю карту признаков до одного значения на канал. Классификатор может использовать эти значения, часто через завершающий полносвязный слой, вместо развёртки всех пространственных позиций.

Наш 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

Разворачивание и классификация

Пулинг обрабатывает каналы независимо, а свёртка может их объединять. В этом классификаторе мы разворачиваем все активации в один вектор, чтобы полносвязный выход был связан с каждой позицией каждого канала. Dense в Keras принимает и тензоры большей размерности, но тогда работает по последней оси, а не разворачивает вход автоматически.

В наших массивах NumPy каналы идут первыми: развёртка читает 196 значений первого канала, затем 196 значений второго. 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)])
Зачем использовать 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, свёрточному слою нужна нетронутая двумерная пространственная структура.

Активация на выходе

Наша модель выдаёт одно число — вероятность того, что на входе круг. Чтобы превратить сырой выход полносвязного слоя (который может быть любым числом) в вероятность от 0 до 1, мы используем сигмоиду:

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 + 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 принимает вход с каналами на последней оси. Здесь используется Adam вместо 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 — гораздо больше паттернов для обнаружения
  • Два блока свёртка+пулинг вместо одного — иерархическое извлечение признаков
  • Softmax на 10 классов вместо бинарной сигмоиды — 10 цифр, которые надо различать
  • ~225 000 параметров вместо 413 — гораздо больше ёмкости

Но строительные блоки те же: вести маленькие фильтры, строя карты признаков, ужимать пулингом, разворачивать, классифицировать. Вот как идёт обучение на протяжении 5 эпох:

Сохранённый запуск MNIST достигает примерно 99,08% точности на валидации после 5 эпох. Графики содержат валидационные метрики, а не отдельную итоговую оценку на тесте. Для сравнения с предыдущей полносвязной сетью нужно оценить обе модели на одном отложенном тестовом наборе.