Разбираемся в CNN — свёртки, карты признаков и пулинг
В предыдущих статьях мы обучили нейросеть на MNIST, используя полносвязные слои, где каждый нейрон одного слоя соединён с каждым нейроном следующего, а веса обновляются обратным распространением. Чтобы работать с полносвязными слоями, нам пришлось разворачивать каждое изображение 28×28 в вектор из 784 элементов и подавать его на слой из 128 нейронов. Один только первый слой имел весов + смещений = параметров — каждый входной пиксель соединён с каждым нейроном. Это работало — мы дошли до 97% точности, — но у такого подхода две фундаментальные проблемы:
1. Нет пространственной осведомлённости. Полносвязный слой воспринимает все 784 входа как неупорядоченный список: пиксели (5, 10) и (5, 11) соседи на изображении, но структура слоя никак этот факт не использует. Разворачивание выбросило всю пространственную структуру — пиксели (0, 0) и (27, 27) для него просто два числа в векторе. Если цифра сдвинется на несколько пикселей вправо, для полносвязной сети она выглядит совершенно иначе — те же значения пикселей теперь попадают на другие входные индексы и активируют другие веса. Сети приходится учить один и тот же паттерн для каждой возможной позиции независимо.
2. Слишком много параметров. Для изображения 28×28 сто тысяч параметров ещё терпимо. Но настоящие изображения намного больше. У RGB-картинки 224×224 (типичный размер входа) пикселей цветовых канала значений. Один полносвязный слой из 512 нейронов потребовал бы миллионов параметров — и это только на один слой. Такое дорого обучать и легко переобучить.
Почему слишком много параметров ведёт к переобучению?
Больше параметров — больше ёмкости для запоминания. С 77 миллионами весов и, скажем, всего 50 тысячами обучающих изображений у сети параметров гораздо больше, чем точек данных. Вместо того чтобы учить общие закономерности вроде «петли — это 0, прямые линии — это 1», она по сути может запомнить каждое обучающее изображение дословно, достигая идеальной точности на обучении и проваливаясь на новых, невиданных изображениях.
Свёрточные нейронные сети (CNN) решают обе проблемы разом. Вместо того чтобы смотреть на все пиксели сразу, CNN фокусируется на маленьких локальных областях — например, на фрагменте 3×3 — и учится распознавать паттерны внутри них. Принципиальная разница: полносвязные слои учат глобальные паттерны, задействующие все пиксели сразу, тогда как свёрточные слои учат локальные паттерны и переиспользуют один и тот же детектор в каждой позиции изображения.
Диаграмма ниже это показывает. Представьте, что нам нужно отличать квадраты от кругов на сетке 28×28. Для этого нужно замечать локальные различия: в углу квадрата вы видите резкий прямой угол, а на границе круга — плавный изгиб. Эти локальные фрагменты — как раз то, что CNN учится обнаруживать:
Использование CNN позволяет напрямую снять обе проблемы:
- Пространственная осведомлённость: поскольку фильтр работает с локальной двумерной областью, он естественным образом понимает, что соседние пиксели связаны. Он обнаруживает паттерны вроде границ и изгибов исходя из того, как пиксели расположены в пространстве, а не просто из их позиции в плоском списке.
- Гораздо меньше параметров: у фильтра 3×3 всего 9 параметров (плюс смещение), и один и тот же фильтр переиспользуется в каждой позиции изображения. Это называется разделением весов. Если фильтр научился обнаруживать прямую границу, он обнаружит её и слева, и справа — а полносвязной сети понадобились бы отдельные веса для каждой позиции.
CNN — это архитектура, сделавшая возможным современное компьютерное зрение: она дала прорывы в поиске по изображениям, распознавании сцен в камерах телефонов, OCR и анализе видео и до сих пор широко используется в автономном вождении, медицинской визуализации и робототехнике (хотя передовые системы всё чаще применяют Vision Transformers (ViT) или гибридные подходы). Например, PaddleOCR — одна из самых популярных открытых OCR-систем — использует CNN как ключевую часть своей архитектуры для детекции и распознавания текста.
Помимо изображений, та же идея применима к одномерным данным — скользящие фильтры по звуковым сигналам для распознавания речи (wav2vec 2.0), генерации музыки (WaveNet) и классификации аудио. Даже языковая модель LFM2 от Liquid AI использует одномерные свёртки как базовый строительный блок — 10 из её 16 блоков являются короткодействующими свёрточными слоями для локального смешивания токенов.
Свёртка в нейросетях
Чтобы понять CNN, по-настоящему нужно освоить всего две операции: свёртку (скользить фильтрами по изображению, обнаруживая паттерны) и пулинг (ужать результат, оставив самые сильные сигналы). Их применяют попеременно — свёртка, пулинг, — а затем привычные полносвязные слои выполняют финальную классификацию. Вот как полная модель могла бы выглядеть на Keras:
model = keras.Sequential([
Input(...), # input image
Conv2D(...), # convolution
MaxPooling2D(...), # pooling
Conv2D(...), # convolution
MaxPooling2D(...), # pooling
Flatten(...), # flatten to 1D
Dense(...), # classification
])Свёртка обнаруживает паттерны, пулинг сжимает результат, а полносвязный слой делает финальную классификацию. Вот как выглядит полный конвейер CNN — от пикселей к предсказанию:
Свёрточные слои и слои пулинга вместе образуют экстрактор признаков — они дистиллируют изображение в компактное представление, где квадраты и круги выглядят по-разному. А полносвязный слой в конце делает лёгкую часть: проводит границу решения в этом пространстве признаков.
Шаг свёртки обнаруживает локальные паттерны — он скользит обученными фильтрами по изображению и выдаёт карты признаков, подсвечивающие, где встречается каждый паттерн. Он ничего не классифицирует, а преобразует сырую сетку пикселей в более богатое представление. Это извлечение признаков, та же идея, что и конструирование признаков в классическом ML. Вместо того чтобы подавать сырые данные в классификатор, вы сначала преобразуете их в более полезные представления. Разница в том, что CNN учится сама, какие признаки извлекать: значения ядер находятся в процессе обучения, а не проектируются вручную.
Шаг пулинга пространственно сжимает каждую карту признаков — уменьшает ширину и высоту, сохраняя самые сильные сигналы. Это уменьшает количество значений, которые придётся обработать полносвязному слою (с 28×28×2 = 1568 до 14×14×2 = 392 в нашей модели), и делает признаки устойчивее к небольшим сдвигам позиции.
К концу статьи каждая строка станет понятной, и вы увидите реальные значения фильтров, которые выучила эта сеть. Начнём со свёртки.
Свёртка как преобразование изображения
Свёртка — не изобретение ML: это фундаментальная техника из обработки сигналов и изображений, существовавшая задолго до глубокого обучения. Если вы когда-нибудь применяли фильтр размытия, резкости или выделения границ в графическом редакторе — это была свёртка. Под капотом каждый фильтр — это маленькая сетка чисел (называемая ядром), скользящая по изображению. В каждой позиции каждое значение ядра умножается на перекрывающийся с ним пиксель, произведения складываются, и результат записывается в выход.
Попробуйте переключаться между разными фильтрами выше. Следите за двумя вещами: за значениями ядра посередине (сетка чисел 3×3) и за тем, как меняется выход справа. Разные фильтры дают разные преобразования:
Обратите внимание, как детектор горизонтальных границ подсвечивает верхний и нижний края фигуры: это достигается тем, что в верхней строке ядра стоят отрицательные значения, а в нижней — положительные, так что он реагирует там, где яркость меняется по вертикали. У фильтра резкости большое положительное значение в центре и отрицательные соседи — он усиливает разницу между пикселем и его окружением, делая границы чётче.
Это классические, хорошо известные ядра из обработки изображений, придуманные вручную за десятилетия до появления глубокого обучения. Описание ниже объясняет, что вычисляет каждое ядро и почему выход выглядит именно так.
| Фильтр | Как он работает |
|---|---|
| Размытие | Усредняет все 9 пикселей поровну — сглаживает различия. Однородная область остаётся такой же, но резкие переходы размываются |
| Резкость | Усиливает центральный пиксель относительно соседей. В гладких областях соседи взаимно гасятся; на границах несовпадение преувеличивается |
| Горизонтальные границы | Вычитает верхние пиксели из нижних — если они похожи, результат близок к нулю. Большое значение выходит только там, где яркость меняется по вертикали, то есть на горизонтальной границе |
| Вертикальные границы | Та же идея, повёрнутая: вычитает левое из правого. Выход силён только там, где яркость меняется по горизонтали, то есть на вертикальной границе |
Та же скользящая операция, тот же вход — разные результаты. Девять чисел в ядре полностью определяют, как будет выглядеть выход. Отличное визуальное объяснение того, как работают свёртки, есть в этом видео от 3Blue1Brown.
Свёртка как поиск совпадений по образцу
Есть и другой способ думать о свёртке. Что если вместо ядра, спроектированного вручную для выделения границ или размытия, взять в качестве ядра фрагмент самого изображения? Мы можем взять маленький кусочек картинки и спросить: где ещё встречается что-то похожее? Это сопоставление с образцом — свёртка находит, где локальный паттерн встречается по всему изображению.
Кликните по любой части фигуры ниже, чтобы вырезать фрагмент 3×3 и использовать его как ядро. Например, кликните по вертикальной грани квадрата, чтобы увидеть, как подсвечиваются все вертикальные границы, или по закруглению круга, чтобы увидеть, где встречаются похожие изгибы. Виджет проводит ядро по всему изображению, и выход загорается там, где найдены похожие паттерны. Можно также нажать Play, чтобы пошагово посмотреть механику скольжения:
Покликайте по разным местам — заметьте, как каждое даёт свой рисунок совпадений. Это свёртка как пространственный детектор паттернов. Ядро задаёт, что искать, а выход сообщает, где это нашлось. Именно это свойство CNN и используют — не размытие и не повышение резкости, а способность обнаруживать выученные паттерны по всему изображению. Фильтры размытия и границ из предыдущего раздела были полезны для интуиции, но в CNN значения ядра выучиваются автоматически и работают как детекторы паттернов. Всё остальное в CNN строится на этой одной идее.
От ручных фильтров к выученным
Оба применения свёртки — преобразование изображения и сопоставление с образцом — опираются на удачный выбор значений ядра. В обработке изображений ядра десятилетиями подбирали вручную (размытие, резкость и фильтры Собеля в демо выше). Они полезны, но подбирать их руками утомительно и ограниченно.
Ключевая идея CNN такова: вместо того чтобы проектировать эти значения вручную, пусть сеть выучит их сама под конкретную задачу. Фильтр стартует со случайных чисел и обновляется градиентным спуском во время обучения — так же, как учатся веса полносвязных слоёв. Сеть сама обнаруживает, какие паттерны важны для задачи. Для нашей задачи «квадрат против круга» она может выучить фильтры, по-разному реагирующие на прямые и на изогнутые границы — то есть на всё, что помогает разделить два класса.
Операция свёртки
Выше мы увидели, как свёртки помогают преобразовывать изображения и искать совпадения по образцу. Теперь посмотрим на саму механику: что именно происходит в каждой позиции и как выстраивается выход, называемый картой признаков?
Почему «карта признаков»?
Слово «признак» здесь используется в том же смысле, что и в ML вообще, — измеримое свойство, полезное для задачи. Как табличная модель может использовать признаки вроде «возраст» или «доход», признак в CNN — это визуальный паттерн вроде «вертикальная граница» или «изгиб». Карта признаков — это пространственная сетка, показывающая, где по изображению этот признак был обнаружен: высокие значения там, где паттерн присутствует, низкие — где нет.
Основная операция проста: взять маленькую матрицу (ядро), скользить ею по входу и в каждой позиции вычислять скалярное произведение — умножить каждое значение ядра на перекрывающийся пиксель, сложить произведения — и записать результат в карту признаков. Ядро может быть любого размера (1×1, 5×5, 7×7) — старые сети вроде AlexNet использовали ядра 11×11, — но 3×3 самый частый выбор в современных архитектурах, его мы и будем использовать по всей статье.
Посмотрим на практике. Скажем, вы кликнули по левой грани квадрата в демо выше — ядро стало фрагментом 3×3 с чёрным (0) фоном и белым (255) там, где проходит линия границы. Когда это ядро доезжает до другой левой границы, фрагмент изображения выглядит так же — совпадает каждая позиция, поэтому произведения велики:
Теперь применим то же ядро к верхней границе. Белые пиксели стоят в других местах — белый столбец ядра перекрывается в основном с чёрными пикселями:
Над ровной чёрной областью каждый пиксель равен 0, поэтому каждое произведение равно 0 — совпадения нет вовсе. Выход силён только там, где ядро и фрагмент изображения согласуются. Развернём, что происходит: мы вытягиваем и ядро 3×3, и область изображения 3×3 в векторы из 9 элементов и вычисляем их скалярное произведение. Чем более похожи два вектора, тем больше результат — вот почему совпадающие области дают высокие значения, а несовпадающие нет.
Та же математика объясняет и размытие. У ядра размытия каждое значение равно ⅑, так что взвешенная сумма — это просто среднее девяти пикселей. На фрагменте с левой границей:
В этой одной позиции выходной пиксель равен ~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:
Напомним, что в каждой позиции фрагмент 3×3 (9 значений) сжимается в одно выходное значение через взвешенную сумму. Так происходит всегда. Выход уменьшается не из-за этого сжатия, а потому что допустимых позиций меньше, чем входных пикселей: ядро просто не может начаться там, где оно свисало бы за край. Общая формула: . Для наших фигур 28×28 (и цифр MNIST того же размера): .
Дополнение
Чтобы противодействовать краевому эффекту, можно добавить вокруг входа строки и столбцы нулей — это называется дополнением (padding). Для ядра 3×3 добавляют 1 пиксель нулей с каждой стороны, так что вход 28×28 становится дополненным входом 30×30, а выход снова 28×28. Для ядра 5×5 добавляли бы по 2 пикселя с каждой стороны.
Мы добавляем именно нули, а не, скажем, единицы, потому что ноль, умноженный на любое значение ядра, равен нулю — дополняющие пиксели ничего не вносят в скалярное произведение. На результат влияют только настоящие пиксели, перекрывающиеся с ядром. Это как сказать: «за границей изображения ничего нет».
Шаг свёртки
До сих пор мы предполагали, что ядро сдвигается на один пиксель за раз — шаг (stride) 1. Но расстояние между соседними позициями ядра — это параметр, который можно менять. Например, при шаге 2 ядро прыгает сразу через 2 пикселя, пропуская каждую вторую позицию — и по горизонтали, и по вертикали. Больший шаг означает меньше позиций, а значит, и меньший выход.
Возьмём тот же вход 5×5, что и раньше, но теперь с шагом 2. Ядро можно поставить только в позиции (0,0), (0,2), (2,0) и (2,2) — всего 4 позиции вместо 9, что даёт выход 2×2. Ширина и высота уменьшаются вдвое каждая:
Шаг даёт пространственное прореживание — уменьшение ширины и высоты карт признаков. Это важно, потому что заставляет сеть дистиллировать локальные паттерны в более компактные представления, уменьшает число параметров в последующих слоях и даёт более глубоким слоям более широкое рецептивное поле по исходному изображению. Достигается это простым пропуском позиций во время свёртки — выученное ядро работает и как детектор паттернов, и как прореживатель.
Однако есть другая техника, которая в классификационных сетях используется чаще: 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, и он реагирует только на эти пиксели, полностью игнорируя остальное изображение. У каждого нейрона своё рецептивное поле, и вместе они замощают весь вход.
Именно здесь контраст с полносвязными слоями становится самым резким. У полносвязного нейрона свои веса и он видит весь вход. Свёрточный «нейрон» противоположен по обоим пунктам: он видит только своё маленькое рецептивное поле и — что принципиально — делит одни и те же веса со всеми остальными нейронами слоя, а именно ядро. Четыре нейрона выше — это не 4 независимых детектора; это один и тот же детектор, вычисленный в 4 местах. Вот что такое разделение весов со стороны нейрона: один детектор паттерна, размноженный по каждому рецептивному полю изображения.
Это же чинит проблему сдвига, с которой мы начинали. Поскольку один и тот же детектор сидит над каждым рецептивным полем, паттерн находится где бы он ни появился: граница в левом верхнем углу зажигает левый верхний нейрон, а та же граница, сдвинутая вниз, попадает в рецептивное поле другого нейрона и зажигает его столь же сильно. Признак обнаруживается независимо от позиции. Это свойство обычно называют инвариантностью к сдвигу, хотя строго свёрточный слой эквивариантен: сдвиньте вход — и карта признаков сдвинется вместе с ним; настоящую инвариантность из малых сдвигов делает уже пулинг, при котором дрожание в один пиксель вообще не меняет выход.
А когда слои складываются в стек, рецептивное поле нейрона растёт: нейрон второго слоя читает фрагмент выходов первого слоя, каждый из которых уже суммировал свой фрагмент пикселей, так что косвенно он видит более широкую область исходного изображения. Это тот самый «более широкий обзор», к которому мы вернёмся в разделах про шаг и глубину.
Вот реализация свёрточного слоя с нуля, показывающая всю операцию целиком:
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)Взвешенная сумма в каждой позиции плюс член смещения — вот и вся операция. Смещение (по одному на ядро) сдвигает выход вверх или вниз, задавая ядру порог того, насколько сильно паттерн должен совпасть, прежде чем оно активируется. Работает ровно как смещение в полносвязном слое.
Как ядра учатся: обратное распространение в CNN
Значения ядер стартуют случайными и обновляются обратным распространением, как и в полносвязных сетях. Ключевое отличие — разделение весов. В полносвязном слое каждый вес соединён с одним конкретным входом и используется один раз за прямой проход, поэтому градиент каждого веса приходит от единственной пары вход × ошибка. В свёрточном слое одни и те же 9 весов переиспользуются в каждой позиции изображения, поэтому при обратном проходе каждая позиция вносит вклад в градиент одних и тех же весов. Следующий фрагмент передаёт идею:
# 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 positionsЗдесь error — это сетка значений градиента, вычисленная вышестоящими слоями во время обратного прохода; её конкретные значения зависят от функции потерь и промежуточных слоёв, о чём мы поговорим в следующих статьях.
Но структура в обоих случаях одинакова: вход × ошибка. Разница в том, что полносвязный вес видит одну такую пару, а свёрточный вес накапливает их со всех позиций, где ядро применялось.
Вот как это накопление выглядит в нашем классе 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_biasesСравните это с методом forward: те же вложенные циклы, только вместо вычисления выходных значений мы накапливаем градиенты. Каждая позиция, где применялось ядро, вносит вклад в один и тот же градиент — вот как 9 весов могут учиться на тысячах позиций по всему изображению.
Несколько ядер, несколько карт признаков
Одно ядро может обнаруживать лишь один тип паттерна. Детектор горизонтальных границ находит горизонтальные границы, но пропускает вертикальные, углы и изгибы. Чтобы схватить несколько аспектов входа, нужно несколько ядер, работающих параллельно. Вот тот же квадрат, обработанный двумя разными ядрами — каждое даёт свою карту признаков, подсвечивающую свою структуру:
Отсюда необходимость использовать несколько ядер на слой, чтобы получить несколько карт признаков — по одной на ядро, каждая обнаруживает свой паттерн. Так что каждый свёрточный слой выдаёт столько карт признаков, сколько у него ядер: у нас их 2, но это намеренный минимум.
Типичные сети используют 32 или 64 ядра в первом слое. Каждое ядро — отдельная сетка 3×3 (размер настраивается) выученных значений, и все они применяются к одному и тому же входу независимо и параллельно: ядро 1 скользит по всему изображению, порождая карту признаков 1, ядро 2 — карту признаков 2, и так далее. Каждое ядро учится обнаруживать свой паттерн; этот параллелизм и позволяет сети схватывать несколько аспектов входа одновременно, а не искать что-то одно за раз.
Вот что получилось после обучения нашей CNN с двумя фильтрами — значения ядер, к которым она сошлась, дают заметно разные карты признаков для квадратов и кругов. Это карты признаков нашего единственного свёрточного слоя; в более глубокой сети каждый слой давал бы свой набор карт признаков, и более глубокие слои схватывали бы всё более сложные паттерны:
Каждый фильтр вносит в итоговую классификацию свой аспект. Фильтр 1 (слева) работает как широкий детектор границ: у квадрата он даёт резкие светлые и тёмные полосы вдоль каждой стороны, у круга — плавный градиент по окружности. Фильтр 2 (справа) реагирует на углы и смены направления: на квадрате он срабатывает в четырёх углах, на круге даёт чередующийся узор там, где кривая изгибается.
Вместе два фильтра создают отличительную сигнатуру каждой формы: у карт признаков квадрата резкие локализованные активации на гранях и углах, у круга — плавные и равномерно распределённые по кольцу. Полносвязный слой в конце учится читать эти сигнатуры: «острые углы» — квадрат, «гладкое кольцо» — круг.
Что удивительно, так это что мы не говорили фильтрам, что обнаруживать: оба стартовали с разных случайных значений, и градиентный спуск толкал каждый туда, где потери падали сильнее всего. Они в итоге специализируются, потому что если бы оба фильтра выучили один и тот же паттерн, один был бы избыточен и не помогал бы дальше снижать потери. Давление минимизировать потери естественным образом разводит фильтры, и каждый схватывает свой аспект входа.
Стек слоёв: от границ к формам
Наша задача «квадрат против круга» настолько проста, что хватает одного свёрточного слоя — 2 фильтра разделяют два класса. Но для задач посложнее вроде распознавания цифр или детекции лиц нужна большая глубина.
Настоящая сила CNN проявляется при укладке свёрточных слоёв в стек. Чтобы понять почему, подумайте, как вы разложили бы задачу распознавания вручную. Если бы вас попросили обнаружить цифру «0», вы могли бы разбить это на подзадачи: есть ли верхний изгиб? нижний изгиб? левая граница? правая граница? Каждая подсеть отвечает на один вопрос, а финальный слой объединяет их выходы:
Каждую из этих подзадач можно разложить дальше. «Верхний изгиб?» распадается на: есть ли дуга слева вверху? дуга справа вверху? горизонтальная граница на вершине? Каждый вопрос становится проще и ближе к сырым визуальным признакам:
Именно этому и учатся автоматически уложенные в стек свёрточные слои — иерархическому извлечению признаков. Первый слой находит границы, второй объединяет границы в изгибы и углы, более глубокие слои складывают из них части и формы. Сети не нужно указывать, какие признаки искать, — она выучивает всю иерархию из данных.
И на каждом уровне параллельно работает множество ядер: одно может научиться обнаруживать горизонтальные границы, другое — вертикальные, одно находит прямые углы, другое — плавные изгибы. Вот почему в каждом слое много ядер: сети нужно отслеживать множество разных паттернов одновременно на каждой ступени иерархии.
Несколько входных каналов
Всё сказанное выше описывает первый свёрточный слой иерархии, где каждое ядро получает на вход одно двумерное изображение. Но более глубокие слои стека работают с чем-то другим. Первый свёрточный слой берёт изображение в градациях серого — одну двумерную сетку 28×28. А вход второго свёрточного слоя — уже не плоское 2D-изображение, это выход первого слоя, стопка двумерных карт признаков, то есть трёхмерный объём (28×28×2 в нашем случае или 28×28×32 в типичной сети):
Эти сложенные в стопку слои часто называют каналами. Каждый канал — проекция одного ядра предыдущего слоя: например, канал 1 может быть «границевым» видом входа, а канал 2 — «угловым». Так что «канал» и «карта признаков» — одно и то же, просто с разных точек зрения: свёрточный слой выдаёт карты признаков, а следующий слой получает их как входные каналы. Слово «канал» используется потому, что оно не подразумевает ни порядка, ни иерархии: это параллельные компоненты одних и тех же данных, каждый описывает свой аспект той же пространственной области.
Раз вход теперь содержит несколько сложенных карт признаков (несколько каналов), ядру нужно обработать их все. Ядро автоматически отражает структуру входа — его глубина (число срезов) выводится так, чтобы совпасть с числом входных каналов. У каждого слоя по-прежнему может быть — и обычно бывает — несколько ядер, но теперь каждое ядро становится трёхмерным: его глубина автоматически совпадает с числом входных каналов.
При 2 входных каналах каждое ядро становится стопкой из двух матриц 2×2, по одной на канал. В каждой позиции первая матрица умножается на канал 1, вторая — на канал 2, и все произведения складываются в одно-единственное выходное значение на позицию. Скольжение такого трёхмерного ядра по всем позициям даёт всего одну карту признаков как выход одного ядра — по одному значению на позицию.
Диаграмма ниже показывает две позиции, чтобы проиллюстрировать, как одно и то же ядро выдаёт разные значения, которые все вносят вклад в эту одну карту признаков:
Операция — та же взвешенная сумма, что мы уже знаем, просто выполненная по одному разу на канал, с последующим сложением результатов. На диаграмме ядро 2×2 скользит по входу 3×3 с 2 каналами. В каждой позиции мы считаем взвешенную сумму отдельно по каждому каналу, а затем складываем результаты:
По мере углубления сети каждое ядро становится больше: ядро первого слоя — 3×3×1 (9 весов), а ядро второго слоя с 2 входными каналами — 3×3×2 (18 весов). Число каналов по сети обычно растёт (1 → 2 → 32 → 64), так что ядра тоже становятся глубже, а пространственный размер (3×3) обычно остаётся прежним.
Многоканальный вход даёт более глубоким слоям возможность комбинировать паттерны предыдущего слоя. Вспомните карты признаков нашей модели: фильтр 1 обнаруживал границы, фильтр 2 — углы. Если бы у нас был второй свёрточный слой, его ядра смотрели бы на обе карты признаков в каждой позиции одновременно. Позиция, где фильтр 1 нашёл вертикальную границу, а фильтр 2 — горизонтальную, — это угол. Ядро второго слоя учится комбинировать эти сигналы: «здесь вертикальная граница, и в том же месте есть горизонтальная — значит, это угол». Так более глубокие слои строят сложные признаки из простых.
Пулинг
После того как свёрточный слой обнаружил признаки, мы хотим ужать каждую карту признаков — как при уменьшении изображения, только вместо усреднения пикселей мы оставляем лишь самые сильные сигналы. Каждая карта признаков ужимается независимо — пулинг не объединяет каналы. Глубина остаётся прежней: 28×28×2 становится 14×14×2, а не 14×14×1.
Слой пулинга получает стопку карт признаков от свёрточного слоя, пространственно ужимает каждую и передаёт дальше то же число каналов. Никакого объединения, никакого обучения — только пространственное сжатие. Это служит двум целям: меньше значений — меньше вычислений в последующих слоях, и это даёт сети пространственную инвариантность — признак, обнаруженный в пикселе (10, 12), и признак в пикселе (11, 12) выживают как одно и то же значение после пулинга, делая сеть устойчивой к малым сдвигам.
Есть разные стратегии пулинга — максимум, среднее и другие, — но самая распространённая это max pooling: берём окно 2×2 и ведём его по карте признаков с шагом 2; поскольку шаг совпадает с размером окна, каждое окно покрывает свежую область без перекрытий. В каждой позиции оставляем только максимальное значение.
Пулинг может показаться похожим на свёртку с шагом: оба ведут окно и прореживают. Но различий два, и они важны. Во-первых, ничего не обучается: у пулинга нет ни весов, ни ядра, ни параметров, обновляемых при обучении. Во-вторых, сама операция другая: вместо взвешенной суммы (умножить и сложить) пулинг просто берёт максимум.
Так что пулинг — чистое преобразование, фиксированное правило, применяемое к данным, без чего-либо обучаемого. В отличие от свёрточных и полносвязных слоёв, слои пулинга не хранят ни весов, ни смещений, вообще никаких параметров. Поэтому в подсчёте параметров модели у них 0. Их можно считать «шагом сжатия», стоящим между обучаемыми слоями и уменьшающим пространственный размер с сохранением важных сигналов.
В нашей модели пулинг сводит карту признаков 28×28 к 14×14:
Max pooling сохраняет самую сильную активацию в каждой области. Если граница была обнаружена где-то во фрагменте 2×2, максимум сохраняет этот сигнал — и неважно, какой именно из 4 пикселей дал самый сильный отклик. Это даёт сети терпимость к малым сдвигам: если фигура сместится на один пиксель, та же область после пулинга по-прежнему схватит тот же признак. Плата за это — потеря точной информации о позиции: после пулинга вы знаете, что признак обнаружен где-то в этой области 2×2, но не знаете, в каком именно пикселе.
Average pooling сглаживает вместо того, чтобы обострять: он берёт среднее всех значений в окне. Это даёт более мягкий выход, но может размывать сильные сигналы — одна сильная активация границы усредняется со слабыми соседями, теряя влияние. Max pooling чаще встречается в ранних и средних слоях, где важно сохранить сильные отклики. Average pooling иногда появляется в самом конце сети как глобальный average pooling, который усредняет каждую карту признаков целиком до одного числа, полностью заменяя связку flatten + полносвязный слой.
Вот наш класс MaxPool2D — обратите внимание, что у него нет метода update, потому что учить нечего:
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 parametersРазворачивание и классификация
Заметьте, как карты признаков остаются раздельными на всём протяжении свёртки и пулинга: каждая ужимается независимо и никогда не сливается с другими. Свёрточные слои извлекают признаки, слои пулинга их сжимают, но в конце у нас всё ещё трёхмерный объём (14×14×2 в нашей модели). Полносвязный слой с этим работать не может — ему нужен плоский одномерный вектор.
Это и делает слой Flatten: он берёт каждое значение из каждого канала и выстраивает их в один длинный вектор — все 14×14 = 196 значений из канала 1, затем все 196 из канала 2, что даёт единый вектор из 392 элементов. Ни вычислений, ни обучения — только изменение формы.
Это то же самое изменение формы, что мы делали в модели MNIST только из полносвязных слоёв, когда разворачивали 28×28 в 784. Сам Flatten не знает и не интересуется, что он переформатирует, — операция идентична. Разница в том, что сделали слои перед ним: в чисто полносвязном подходе предыдущих слоёв не было, поэтому Flatten получал сырые пиксели. Здесь свёртка и пулинг уже извлекли и сжали пространственные паттерны, так что полносвязный слой получает осмысленные признаки, а не сырые значения пикселей.
Дальше полносвязный слой работает как классификатор. Поскольку у нас всего два класса (квадрат против круга), это бинарная классификация — нужен всего один нейрон с сигмоидной активацией. Он соединяется со всеми 392 значениями, умножает их на выученные веса, складывает, добавляет смещение и выдаёт одно число между 0 и 1: вероятность того, что на входе круг. Близко к 0 — квадрат, близко к 1 — круг. Для многоклассовых задач вроде MNIST (10 цифр) вместо этого использовали бы 10 нейронов с softmax — по одному на класс.
Здесь сеть принимает окончательное решение. Свёрточные слои сделали трудную работу — превратили сырые пиксели в осмысленные признаки вроде «здесь граница», «там угол». Полносвязному слою остаётся лишь провести границу решения в этом 392-мерном пространстве признаков, отделив похожие на квадрат паттерны от похожих на круг.
Вот класс 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 кругов) разных размеров и в разных позициях:
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)])Почему np.mgrid ускоряет генерацию кругов
Обратите внимание, что make_circle использует np.mgrid вместо вложенного цикла Python по всем 28×28 пикселям. np.mgrid создаёт сетку всех координат пикселей разом, после чего NumPy вычисляет расстояние от центра для всех 784 пикселей одним векторизованным вызовом — вообще без цикла на Python. Для 2000 кругов это разница между секундами и минутами.
Затем нормализуем и разбиваем:
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:]Заметьте, что мы не разворачиваем изображения: в отличие от полносвязной модели MNIST, где мы переформатировали их в векторы по 784, свёрточному слою нужна нетронутая двумерная пространственная структура.
Активация на выходе
Наша модель выдаёт одно число — вероятность того, что на входе круг. Чтобы превратить сырой выход полносвязного слоя (который может быть любым числом) в вероятность от 0 до 1, мы используем сигмоиду:
def sigmoid(x):
return 1 / (1 + np.exp(-x))Это та же функция, что применяется в логистической регрессии, — она сжимает любое значение в интервал (0, 1). Для многоклассовых задач вроде MNIST мы использовали бы softmax.
Функция потерь
Чтобы измерить, насколько предсказание ошибочно, мы используем бинарную кросс-энтропию — ту же идею, что и кросс-энтропийные потери из статьи про MNIST, но адаптированную к двум классам вместо десяти:
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)Она измеряет, насколько предсказанная вероятность далека от истинной метки: если модель говорит 0.95 для круга и это действительно круг, потери малы; если говорит 0.3 — потери велики.
Цикл обучения
Цикл обучения следует тому же процессу из 4 шагов, что и в предыдущих статьях: прямой проход, потери, обратное распространение, градиентный спуск. Прямой проход сцепляет все наши слои:
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 outputОбратный проход зеркалит его в обратную сторону — от градиента потерь назад через полносвязный слой, обратное разворачивание, пулинг и свёртку. Почему output - label является правильным стартовым градиентом для связки сигмоида + бинарная кросс-энтропия, мы подробно разберём в следующей статье:
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 positionsКогда прямой и обратный проходы определены, цикл обучения прост:
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)Результаты обучения
Мы обучались на 4000 сгенерированных фигур (2000 квадратов + 2000 кругов). Вот как идёт обучение на протяжении 20 эпох — потери падают, а точность растёт и на обучающей, и на валидационной выборке:
Модель достигает 100% точности на тесте, а выученные фильтры — это те самые, что вы видели в демо с картами признаков выше.
Для сравнения — эквивалент на Keras: вся модель и цикл обучения выше схлопываются в несколько строк:
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)Масштабируемся: от фигур к 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 параметров вместо 359 — гораздо больше ёмкости
Но строительные блоки те же: вести маленькие фильтры, строя карты признаков, ужимать пулингом, разворачивать, классифицировать. Вот как идёт обучение на протяжении 5 эпох:
Эта CNN достигает ~99% точности на тесте на MNIST за 5 эпох — заметное улучшение по сравнению с ~97%, которые мы получали на одних полносвязных слоях.