Фаза 04 · урок 02
Свёртки с нуля
Цель урока: Полносвязному слою для RGB-изображения 224x224 потребуется 224 224 3 = 150,528 входных весов на нейрон. Один скрытый слой с 1 000 нейронов уже имеет 150 миллионов параметров — ещё до того, как вы изучили что-либо полезное. Хуже того,…
Текущий релиз AlexBred.com: первые 100 уроков русскоязычной программы.
Содержание урока
- Цели обучения
- Проблема
- Концепция
- Одно скользящее ядро
- Формула размера выхода
- Дополнение
- Шаг
- Несколько входных каналов
- Трюк im2col
- Рецептивное поле
- Соберите
- Шаг 1: Дополните массив
- Шаг 2: Двумерная свёртка с вложенными циклами
- Шаг 3: Проверка с вручную спроектированным ядром
- Шаг 4: im2col
- Шаг 5: Быстрая свёртка через im2col + matmul
- Шаг 6: Набор вручную спроектированных ядер
- Используйте
- Внедрите
- Упражнения
- Ключевые термины
- Дополнительное чтение
Свёртка — это небольшой полносвязный слой, который скользит по изображению и использует одни и те же веса в каждой позиции.
Тип: Сборка Языки: Python Предварительные требования: Фаза 03 («Основы глубокого обучения»), фаза 04, урок 01 («Основы изображений») Время: ~75 минут
Цели обучения
- Реализовать двумерную свёртку с нуля, используя только NumPy, включая вариант с вложенными циклами и векторизованный вариант
im2col - Вычислять пространственный размер выхода для любого сочетания размера входа, размера ядра, дополнения и шага, а также обосновывать формулу
(H - K + 2P) / S + 1 - Проектировать вручную ядра (границы, размытие, повышение резкости, Sobel) и объяснять, почему каждое из них создаёт соответствующий паттерн активаций
- Объединять свёртки в экстрактор признаков и связывать глубину стека с размером рецептивного поля
Проблема
Полносвязному слою для RGB-изображения 224x224 потребуется 224 * 224 * 3 = 150,528 входных весов на нейрон. Один скрытый слой с 1 000 нейронов уже имеет 150 миллионов параметров — ещё до того, как вы изучили что-либо полезное. Хуже того, такой слой не понимает, что собака в верхнем левом и собака в нижнем правом углу — один и тот же паттерн. Он рассматривает каждую позицию пикселя как независимую, что для изображений в корне неверно: сдвиг кошки на три пикселя не должен вынуждать сеть заново изучать это понятие.
Модели изображений нужны два свойства: эквивариантность к сдвигу (выход сдвигается при сдвиге входа) и совместное использование параметров (один и тот же детектор признаков работает везде). Полносвязные слои не дают ни того, ни другого. Свёртка даёт оба свойства бесплатно.
Свёртка придумана не для глубокого обучения. Это та же операция, которая используется в сжатии JPEG, гауссовом размытии в Photoshop, обнаружении границ в промышленном зрении и во всех когда-либо поставлявшихся аудиофильтрах. Причина доминирования CNN в ImageNet с 2012 по 2020 год в том, что свёртка задаёт правильное априорное предположение для данных, где соседние значения связаны, а один и тот же паттерн может возникнуть где угодно.
Концепция
Одно скользящее ядро
Двумерная свёртка берёт небольшую матрицу весов, называемую ядром (kernel) или фильтром, скользит ею по входу и в каждой позиции вычисляет сумму поэлементных произведений. Эта сумма становится одним выходным пикселем.
Конкретный пример с ядром 3x3 и входом 5x5 (без дополнения, шаг 1):
Input X (5 x 5): Kernel W (3 x 3):
1 2 0 1 2 1 0 -1
0 1 3 1 0 2 0 -2
2 1 0 2 1 1 0 -1
1 0 2 1 3
2 1 1 0 1
The kernel slides across every valid 3 x 3 window. Output Y is 3 x 3:
Y[0,0] = sum( W * X[0:3, 0:3] )
Y[0,1] = sum( W * X[0:3, 1:4] )
Y[0,2] = sum( W * X[0:3, 2:5] )
Y[1,0] = sum( W * X[1:4, 0:3] )
... and so on
Эта одна формула — общие веса, локальность, скользящее окно — и есть вся идея. Всё остальное — учёт деталей.
Формула размера выхода
При пространственном размере входа H, размере ядра K, дополнении P и шаге S:
H_out = floor( (H - K + 2P) / S ) + 1
Запомните её. Вы будете вычислять её десятки раз для каждой архитектуры.
| Сценарий | H | K | P | S | H_out |
|---|---|---|---|---|---|
| Валидная свёртка без дополнения | 32 | 3 | 0 | 1 | 30 |
| Свёртка same (сохраняет размер) | 32 | 3 | 1 | 1 | 32 |
| Понижающая дискретизация в 2 раза | 32 | 3 | 1 | 2 | 16 |
| Пулинг 2x2 | 32 | 2 | 0 | 2 | 16 |
| Большое рецептивное поле | 32 | 7 | 3 | 2 | 16 |
«Same padding» означает выбрать P так, чтобы H_out == H при S == 1. Для нечётного K это P = (K - 1) / 2. Вот почему ядра 3x3 преобладают: это наименьшее нечётное ядро, у которого всё ещё есть центр.
Дополнение
Без дополнения каждая свёртка уменьшает карту признаков. Если сложить 20 таких свёрток, изображение 224x224 станет 184x184, что тратит вычисления на границу и усложняет остаточные соединения, которым требуются совпадающие формы.
Zero padding (P = 1) on a 5 x 5 input:
0 0 0 0 0 0 0
0 1 2 0 1 2 0
0 0 1 3 1 0 0
0 2 1 0 2 1 0 Now the kernel can centre on pixel
0 1 0 2 1 3 0 (0, 0) and still have three rows and
0 2 1 1 0 1 0 three columns of values to multiply.
0 0 0 0 0 0 0
На практике встречаются режимы: zero (наиболее распространённый), reflect (отражает край, избегая жёстких границ в генеративных моделях), replicate (копирует край), circular (замыкает края, используется в тороидальных задачах).
Шаг
Шаг (stride) — это расстояние, на которое сдвигается окно. stride=1 используется по умолчанию. stride=2 уменьшает пространственные размеры вдвое и является классическим способом выполнить понижающую дискретизацию внутри CNN без отдельного слоя пулинга: каждая современная архитектура (ResNet, ConvNeXt, MobileNet) где-то использует свёртки с шагом вместо max-pool.
Stride 1 on a 5 x 5 input, 3 x 3 kernel:
starts: (0,0) (0,1) (0,2) -> output row 0
(1,0) (1,1) (1,2) -> output row 1
(2,0) (2,1) (2,2) -> output row 2
Output: 3 x 3
Stride 2 on the same input:
starts: (0,0) (0,2) -> output row 0
(2,0) (2,2) -> output row 1
Output: 2 x 2
Несколько входных каналов
У реальных изображений три канала. Свёртка 3x3 для RGB-входа на самом деле представляет собой объём 3x3x3: по одному срезу 3x3 для каждого входного канала. В каждой пространственной позиции выполняется умножение и суммирование по всем трём срезам, затем добавляется смещение.
Input: (C_in, H, W) 3 x 5 x 5
Kernel: (C_in, K, K) 3 x 3 x 3 (one kernel)
Output: (1, H', W') 2D map
For a layer that produces C_out output channels, you stack C_out kernels:
Weight: (C_out, C_in, K, K) e.g. 64 x 3 x 3 x 3
Output: (C_out, H', W') 64 x 3 x 3
Parameter count: C_out * C_in * K * K + C_out (the + C_out is biases)
Последняя строка — та, которую вы будете вычислять при планировании модели. Свёртка 3x3 с 64 каналами для входа с тремя каналами имеет 64 * 3 * 3 * 3 + 64 = 1,792 параметра. Недорого.
Трюк im2col
Вложенные циклы легко читать, но они медленны. GPU нужны большие матричные умножения. Приём состоит в следующем: развернуть каждое окно рецептивного поля входа в один столбец большой матрицы, развернуть ядро в строку — и вся свёртка станет одним matmul.
Каждая производственная реализация свёртки — это вариант этого подхода с приёмами кеш-тайлинга (прямая свёртка, Winograd, FFT-свёртка для больших ядер). Поймите im2col, и вы поймёте основу.
Рецептивное поле
Одна свёртка 3x3 смотрит на 9 входных пикселей. Если сложить две свёртки 3x3, нейрон второго слоя смотрит на область 5x5 входных пикселей. Три свёртки 3x3 дают 7x7. В общем случае:
RF after L stacked K x K convs (stride 1) = 1 + L * (K - 1)
With strides: RF grows multiplicatively with stride along each layer.
Вся причина, по которой подход «3x3 до самого конца» работает (VGG, ResNet, ConvNeXt), в том, что две свёртки 3x3 видят ту же входную область, что и одна свёртка 5x5, но имеют меньше параметров и дополнительную нелинейность между ними.
convolution-kernel
Соберите
Шаг 1: Дополните массив
Начните с наименьшего примитива: функции, добавляющей нули вокруг массива H x W.
import numpy as np
def pad2d(x, p):
if p == 0:
return x
h, w = x.shape[-2:]
out = np.zeros(x.shape[:-2] + (h + 2 * p, w + 2 * p), dtype=x.dtype)
out[..., p:p + h, p:p + w] = x
return out
x = np.arange(9).reshape(3, 3)
print(x)
print()
print(pad2d(x, 1))
Приём с последними осями x.shape[:-2] означает, что та же функция без изменений работает с формами (H, W), (C, H, W) или (N, C, H, W).
Шаг 2: Двумерная свёртка с вложенными циклами
Эталонная реализация — медленная, но однозначная. Именно это в принципе делает torch.nn.functional.conv2d.
def conv2d_naive(x, w, b=None, stride=1, padding=0):
c_in, h, w_in = x.shape
c_out, c_in_w, kh, kw = w.shape
assert c_in == c_in_w
x_pad = pad2d(x, padding)
h_out = (h + 2 * padding - kh) // stride + 1
w_out = (w_in + 2 * padding - kw) // stride + 1
out = np.zeros((c_out, h_out, w_out), dtype=np.float32)
for oc in range(c_out):
for i in range(h_out):
for j in range(w_out):
hs = i * stride
ws = j * stride
patch = x_pad[:, hs:hs + kh, ws:ws + kw]
out[oc, i, j] = np.sum(patch * w[oc])
if b is not None:
out[oc] += b[oc]
return out
Четыре вложенных цикла (выходной канал, строка, столбец и неявная сумма по C_in, kh, kw). Это эталон, с которым вы будете сравнивать каждую более быструю реализацию.
Шаг 3: Проверка с вручную спроектированным ядром
Создайте вертикальное ядро Sobel, примените его к синтетическому ступенчатому изображению и посмотрите, как загорится вертикальная граница.
def synthetic_step_image():
img = np.zeros((1, 16, 16), dtype=np.float32)
img[:, :, 8:] = 1.0
return img
sobel_x = np.array([
[[-1, 0, 1],
[-2, 0, 2],
[-1, 0, 1]]
], dtype=np.float32)[None]
x = synthetic_step_image()
y = conv2d_naive(x, sobel_x, padding=1)
print(y[0].round(1))
Ожидайте большие положительные значения в столбце 7 (увеличение яркости слева направо) и нули везде в остальных местах. Этот единственный вывод — ваша проверка здравого смысла, что математика верна.
Шаг 4: im2col
Преобразуйте каждое окно входа размером с ядро в столбец матрицы. При C_in=3, K=3 в каждом столбце будет 27 чисел.
def im2col(x, kh, kw, stride=1, padding=0):
c_in, h, w = x.shape
x_pad = pad2d(x, padding)
h_out = (h + 2 * padding - kh) // stride + 1
w_out = (w + 2 * padding - kw) // stride + 1
cols = np.zeros((c_in * kh * kw, h_out * w_out), dtype=x.dtype)
col = 0
for i in range(h_out):
for j in range(w_out):
hs = i * stride
ws = j * stride
patch = x_pad[:, hs:hs + kh, ws:ws + kw]
cols[:, col] = patch.reshape(-1)
col += 1
return cols, h_out, w_out
Здесь всё ещё есть Python-цикл, но теперь тяжёлая работа будет одним векторизованным matmul.
Шаг 5: Быстрая свёртка через im2col + matmul
Замените четверной цикл одним матричным умножением.
def conv2d_im2col(x, w, b=None, stride=1, padding=0):
c_out, c_in, kh, kw = w.shape
cols, h_out, w_out = im2col(x, kh, kw, stride, padding)
w_flat = w.reshape(c_out, -1)
out = w_flat @ cols
if b is not None:
out += b[:, None]
return out.reshape(c_out, h_out, w_out)
Проверка корректности: запустите обе реализации и сравните их.
rng = np.random.default_rng(0)
x = rng.normal(0, 1, (3, 16, 16)).astype(np.float32)
w = rng.normal(0, 1, (8, 3, 3, 3)).astype(np.float32)
b = rng.normal(0, 1, (8,)).astype(np.float32)
y_naive = conv2d_naive(x, w, b, padding=1)
y_im2col = conv2d_im2col(x, w, b, padding=1)
print(f"max abs diff: {np.max(np.abs(y_naive - y_im2col)):.2e}")
max abs diff должен быть около 1e-5: разница вызвана порядком накопления чисел с плавающей точкой, а не ошибкой.
Шаг 6: Набор вручную спроектированных ядер
Пять фильтров, показывающих, что может выразить один свёрточный слой до обучения.
KERNELS = {
"identity": np.array([[0, 0, 0], [0, 1, 0], [0, 0, 0]], dtype=np.float32),
"blur_3x3": np.ones((3, 3), dtype=np.float32) / 9.0,
"sharpen": np.array([[0, -1, 0], [-1, 5, -1], [0, -1, 0]], dtype=np.float32),
"sobel_x": np.array([[-1, 0, 1], [-2, 0, 2], [-1, 0, 1]], dtype=np.float32),
"sobel_y": np.array([[-1, -2, -1], [0, 0, 0], [1, 2, 1]], dtype=np.float32),
}
def apply_kernel(img2d, kernel):
x = img2d[None].astype(np.float32)
w = kernel[None, None]
return conv2d_im2col(x, w, padding=1)[0]
При применении к любому изображению в оттенках серого размытие смягчает картинку, повышение резкости делает границы чётче, Sobel-x выделяет вертикальные границы, а Sobel-y — горизонтальные. Именно такие паттерны первый обученный свёрточный слой AlexNet и VGG в итоге научился выделять: хорошей модели изображений нужны детекторы границ и пятен независимо от последующей задачи.
Используйте
nn.Conv2d из PyTorch оборачивает ту же операцию, добавляя autograd, CUDA-ядра и оптимизацию cuDNN. Семантика форм идентична.
import torch
import torch.nn as nn
conv = nn.Conv2d(in_channels=3, out_channels=64, kernel_size=3, stride=1, padding=1)
print(conv)
print(f"weight shape: {tuple(conv.weight.shape)} # (C_out, C_in, K, K)")
print(f"bias shape: {tuple(conv.bias.shape)}")
print(f"param count: {sum(p.numel() for p in conv.parameters())}")
x = torch.randn(8, 3, 224, 224)
y = conv(x)
print(f"\ninput shape: {tuple(x.shape)}")
print(f"output shape: {tuple(y.shape)}")
Замените padding=1 на padding=0, и размер выхода уменьшится до 222x222. Замените stride=1 на stride=2, и он уменьшится до 112x112. Та же формула, которую вы запомнили выше.
Внедрите
Этот урок создаёт:
outputs/prompt-cnn-architect.md— промпт, который по размеру входа, бюджету параметров и целевому рецептивному полю проектирует стек слоёвConv2dс правильными K/S/P на каждом шаге.outputs/skill-conv-shape-calculator.md— навык, который проходит спецификацию сети слой за слоем и возвращает форму выхода, рецептивное поле и число параметров для каждого блока.
Упражнения
- (Легко) Для входа 128x128 в оттенках серого и стека
[Conv3x3(s=1,p=1), Conv3x3(s=2,p=1), Conv3x3(s=1,p=1), Conv3x3(s=2,p=1)]вычислите вручную пространственный размер выхода и рецептивное поле на каждом слое. Проверьте результат с помощьюnn.Sequentialиз фиктивных свёрток PyTorch. - (Средне) Расширьте
conv2d_naiveиconv2d_im2col, чтобы они принимали аргументgroups. Покажите, чтоgroups=C_in=C_outвоспроизводит глубинную свёртку (depthwise convolution) и что число её параметров равноC * K * K, а неC * C * K * K. - (Сложно) Реализуйте вручную обратный проход
conv2d_im2col: по градиенту выхода вычислите градиентыxиw. Проверьте результат черезtorch.autograd.gradна тех же входах и весах. Подсказка: градиентim2col— этоcol2im, и он должен накапливать перекрывающиеся окна.
Ключевые термины
| Термин | Как обычно говорят | Что это действительно означает |
|---|---|---|
| Свёртка | «Скользящий фильтр» | Обучаемое скалярное произведение, применяемое в каждой пространственной позиции с общими весами; математически это кросс-корреляция, но все называют её свёрткой |
| Ядро / фильтр | «Детектор признака» | Небольшой тензор весов формы (C_in, K, K), чьё скалярное произведение с окном входа создаёт один выходной пиксель |
| Шаг | «Насколько далеко прыгаем» | Расстояние между последовательными размещениями ядра; шаг 2 уменьшает каждое пространственное измерение вдвое |
| Дополнение | «Нули на краях» | Дополнительные значения вокруг входа, позволяющие ядру центрироваться на граничных пикселях; дополнение same сохраняет размер выхода равным размеру входа |
| Рецептивное поле | «Сколько видит нейрон» | Участок исходного входа, от которого зависит данная выходная активация; он растёт с глубиной и шагом |
| im2col | «Трюк GEMM» | Перестройка каждого рецептивного окна в столбцы, превращающая свёртку в одно большое матричное умножение — основа каждого быстрого свёрточного ядра |
| Глубинная свёртка | «Одно ядро на канал» | Свёртка с groups == C_in, вычисляющая каждый выходной канал только по соответствующему входному каналу; основа MobileNet и ConvNeXt |
| Эквивариантность к сдвигу | «Сдвиг на входе — сдвиг на выходе» | Свойство, при котором сдвиг входа на k пикселей сдвигает выход на k пикселей; возникает благодаря общим весам |
Дополнительное чтение
- A guide to convolution arithmetic for deep learning (Dumoulin & Visin, 2016) — исчерпывающие схемы дополнения, шага и дилатации, которые незаметно копирует каждый курс
- CS231n: Convolutional Neural Networks for Visual Recognition — канонические лекционные заметки, включая исходное объяснение
im2col - The Annotated ConvNet (fast.ai) — ноутбук, проходящий путь от ручной свёртки до обученного классификатора цифр
- Receptive Field Arithmetic for CNNs (Dang Ha The Hien) — интерактивное объяснение вычислений рецептивного поля качества научной статьи
Источник: Convolutions from Scratch 04.01 — Основы изображений: пиксели, каналы и цветовые пространства · Фаза 4 — Компьютерное зрение · 04.03 — CNN: от LeNet до ResNet · Полный каталог