Фаза 04 · урок 02

Свёртки с нуля

Цель урока: Полносвязному слою для RGB-изображения 224x224 потребуется 224 224 3 = 150,528 входных весов на нейрон. Один скрытый слой с 1 000 нейронов уже имеет 150 миллионов параметров — ещё до того, как вы изучили что-либо полезное. Хуже того,…

Текущий релиз AlexBred.com: первые 100 уроков русскоязычной программы.

Курс
AI Engineering from Scratch
Фаза
Компьютерное зрение
Чтение
15 мин.
Проверено
Содержание урока
  1. Цели обучения
  2. Проблема
  3. Концепция
  4. Одно скользящее ядро
  5. Формула размера выхода
  6. Дополнение
  7. Шаг
  8. Несколько входных каналов
  9. Трюк im2col
  10. Рецептивное поле
  11. Соберите
  12. Шаг 1: Дополните массив
  13. Шаг 2: Двумерная свёртка с вложенными циклами
  14. Шаг 3: Проверка с вручную спроектированным ядром
  15. Шаг 4: im2col
  16. Шаг 5: Быстрая свёртка через im2col + matmul
  17. Шаг 6: Набор вручную спроектированных ядер
  18. Используйте
  19. Внедрите
  20. Упражнения
  21. Ключевые термины
  22. Дополнительное чтение

Свёртка — это небольшой полносвязный слой, который скользит по изображению и использует одни и те же веса в каждой позиции.

Тип: Сборка Языки: 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 — навык, который проходит спецификацию сети слой за слоем и возвращает форму выхода, рецептивное поле и число параметров для каждого блока.

Упражнения

  1. (Легко) Для входа 128x128 в оттенках серого и стека [Conv3x3(s=1,p=1), Conv3x3(s=2,p=1), Conv3x3(s=1,p=1), Conv3x3(s=2,p=1)] вычислите вручную пространственный размер выхода и рецептивное поле на каждом слое. Проверьте результат с помощью nn.Sequential из фиктивных свёрток PyTorch.
  2. (Средне) Расширьте conv2d_naive и conv2d_im2col, чтобы они принимали аргумент groups. Покажите, что groups=C_in=C_out воспроизводит глубинную свёртку (depthwise convolution) и что число её параметров равно C * K * K, а не C * C * K * K.
  3. (Сложно) Реализуйте вручную обратный проход conv2d_im2col: по градиенту выхода вычислите градиенты x и w. Проверьте результат через torch.autograd.grad на тех же входах и весах. Подсказка: градиент im2col — это col2im, и он должен накапливать перекрывающиеся окна.

Ключевые термины

Термин Как обычно говорят Что это действительно означает
Свёртка «Скользящий фильтр» Обучаемое скалярное произведение, применяемое в каждой пространственной позиции с общими весами; математически это кросс-корреляция, но все называют её свёрткой
Ядро / фильтр «Детектор признака» Небольшой тензор весов формы (C_in, K, K), чьё скалярное произведение с окном входа создаёт один выходной пиксель
Шаг «Насколько далеко прыгаем» Расстояние между последовательными размещениями ядра; шаг 2 уменьшает каждое пространственное измерение вдвое
Дополнение «Нули на краях» Дополнительные значения вокруг входа, позволяющие ядру центрироваться на граничных пикселях; дополнение same сохраняет размер выхода равным размеру входа
Рецептивное поле «Сколько видит нейрон» Участок исходного входа, от которого зависит данная выходная активация; он растёт с глубиной и шагом
im2col «Трюк GEMM» Перестройка каждого рецептивного окна в столбцы, превращающая свёртку в одно большое матричное умножение — основа каждого быстрого свёрточного ядра
Глубинная свёртка «Одно ядро на канал» Свёртка с groups == C_in, вычисляющая каждый выходной канал только по соответствующему входному каналу; основа MobileNet и ConvNeXt
Эквивариантность к сдвигу «Сдвиг на входе — сдвиг на выходе» Свойство, при котором сдвиг входа на k пикселей сдвигает выход на k пикселей; возникает благодаря общим весам

Дополнительное чтение


Источник: Convolutions from Scratch 04.01 — Основы изображений: пиксели, каналы и цветовые пространства · Фаза 4 — Компьютерное зрение · 04.03 — CNN: от LeNet до ResNet · Полный каталог