Свёрточные слои

⏱ 45 мин · 🧩 заданий: 7

Почему не полносвязная сеть

В прошлом модуле мы распознавали цифры 8 \times 8 полносвязной сетью, и она справилась. Но с настоящими картинками у неё две беды:

  • Слишком много параметров. Цветная картинка 224 \times 224 — это 224 \cdot 224 \cdot 3 \approx 150\,000 чисел. Один полносвязный слой на 1000 нейронов — 150 миллионов весов. Такую сеть трудно обучить, и она легко переобучится.
  • Нет понятия «где». Для полносвязного слоя вход — просто список чисел. Кошка в левом углу и кошка в правом — совершенно разные входы, и сеть должна выучить кошку отдельно для каждого места. Она даже не знает, какие пиксели — соседи.

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

Свёртка на одномерном сигнале

Начнём с одномерного случая. Ядро (фильтр) — короткий массив весов. Скользим им по сигналу и в каждой позиции считаем скалярное произведение ядра и кусочка сигнала:

\text{out}[i] = \sum_{a=0}^{k-1} x[i + a]\, w[a]
import numpy as np
import vis

rng = np.random.default_rng(0)
t = np.linspace(0, 4 * np.pi, 120)
signal = np.sin(t) + (t > 6) * 1.5 + rng.normal(0, 0.15, len(t))     # синус со ступенькой и шумом

def conv1d(x, w):
    k = len(w)
    return np.array([x[i:i + k] @ w for i in range(len(x) - k + 1)])

smooth = conv1d(signal, np.ones(7) / 7)          # среднее по 7 соседям — сглаживание
edge = conv1d(signal, np.array([-1.0, 0, 1]))   # разность соседей — «производная»

vis.line({"сигнал": signal.tolist(), "сглаживание [1/7 × 7]": [np.nan] * 3 + smooth.tolist(),
          "разность [−1, 0, 1]": [np.nan] + edge.tolist()}, title="Одно и то же действие во всех позициях")

Ядро из одинаковых весов сглаживает шум, ядро [-1, 0, 1] реагирует на резкие изменения — у ступеньки большой всплеск. Главное: одни и те же три веса применяются во всех позициях, поэтому ступенька найдётся, где бы она ни была.

Свёртка на картинке

На картинке ядро — маленький квадрат весов (обычно 3 \times 3), который скользит по изображению слева направо и сверху вниз:

\text{out}[i, j] = \sum_{a}\sum_{b} \text{img}[i + a,\, j + b] \cdot w[a, b] + b_0
вход 5×5 ядро 3×3 выход 3×3 ┌───┬───┬───┬───┬───┐ │ 1 │ 0 │ 2 │ 1 │ 0 │ ┌───┬───┬───┐ ┌───┬───┬───┐ ├───┼───┼───┼───┼───┤ │ 1 │ 0 │-1 │ │ ■ │ │ │ ■ = сумма произведений │ 0 │ 1 │ 3 │ 0 │ 1 │ ⊛ ├───┼───┼───┤ = ├───┼───┼───┤ выделенного окна 3×3 ├───┼───┼───┼───┼───┤ │ 1 │ 0 │-1 │ │ │ │ │ на ядро │ 2 │ 1 │ 0 │ 1 │ 2 │ ├───┼───┼───┤ ├───┼───┼───┤ ├───┼───┼───┼───┼───┤ │ 1 │ 0 │-1 │ │ │ │ │ затем окно сдвигается │ 1 │ 0 │ 1 │ 2 │ 0 │ └───┴───┴───┘ └───┴───┴───┘ на одну клетку ├───┼───┼───┼───┼───┤ │ 0 │ 2 │ 1 │ 0 │ 1 │ └───┴───┴───┴───┴───┘

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

Классические фильтры

До нейросетей ядра для обработки изображений подбирали вручную. Попробуем несколько на простой картинке:

import numpy as np
import nanotorch as nt
import vis

yy, xx = np.mgrid[0:32, 0:32]
img = np.zeros((32, 32))
img[6:16, 4:14] = 1.0                                   # квадрат
img[(yy - 21) ** 2 + (xx - 22) ** 2 < 49] = 0.7         # круг
img[np.abs(yy - (31 - xx)) < 1.5] = 0.4                 # диагональ

kernels = {
    "размытие": np.ones((3, 3)) / 9,
    "верт. границы": np.array([[-1, 0, 1], [-2, 0, 2], [-1, 0, 1]], float),
    "гориз. границы": np.array([[-1, -2, -1], [0, 0, 0], [1, 2, 1]], float),
    "резкость": np.array([[0, -1, 0], [-1, 5, -1], [0, -1, 0]], float),
}
outs = [img]
for k in kernels.values():
    # conv2d из nanotorch: вход (N, C, H, W), ядра (F, C, k, k)
    outs.append(nt.conv2d(nt.tensor(img[None, None]), nt.tensor(k[None, None]), padding=1).data[0, 0])
vis.images(outs, titles=["исходная"] + list(kernels), cols=5)

Ядра Собеля («границы») дают большие по модулю значения там, где яркость меняется слева направо или сверху вниз, и ноль на ровных участках. Знак показывает направление перепада: красный — значение пикселей растёт (слева направо или сверху вниз), синий — убывает. (На картинках большие значения нарисованы тёмным.)

Отступ и шаг

Без отступа выход меньше входа: ядро k \times k помещается только в (n - k + 1) позиций. Чтобы размер не уменьшался, картинку обрамляют нулями — отступ (padding) p. При k = 3 отступ 1 сохраняет размер.

Шаг (stride) s — на сколько пикселей сдвигается ядро. Шаг 2 уменьшает картинку вдвое по каждой стороне. Общая формула:

n_{\text{out}} = \left\lfloor \frac{n + 2p - k}{s} \right\rfloor + 1
import numpy as np
import nanotorch as nt

x = nt.tensor(np.random.default_rng(0).normal(size=(1, 1, 32, 32)))
w = nt.tensor(np.ones((1, 1, 3, 3)))
for p, s in [(0, 1), (1, 1), (1, 2), (0, 2)]:
    out = nt.conv2d(x, w, padding=p, stride=s)
    print(f"padding={p}, stride={s}: {x.shape[2:]} → {out.shape[2:]}")

Каналы

Цветная картинка состоит из трёх каналов (R, G, B), поэтому вход свёрточного слоя имеет форму (C, H, W). Ядро тоже трёхмерное: (C, k, k) — оно смотрит на окно k \times k сразу во всех каналах и выдаёт одно число. Слой содержит F таких ядер (фильтров), и каждое даёт свою карту признаков. Выход — F каналов:

\underbrace{(C_{\text{in}}, H, W)}_{\text{вход}} \xrightarrow{\;F \text{ ядер } (C_{\text{in}}, k, k)\;} \underbrace{(F, H', W')}_{\text{выход}}, \qquad \text{параметров: } F \cdot (C_{\text{in}} k^2 + 1)

Следующий слой получает F каналов и комбинирует найденные признаки: из границ получаются углы и дуги, из них — части объектов.

import numpy as np
import nanotorch as nt

nt.manual_seed(0)
conv = nt.Conv2d(3, 16, kernel_size=3, padding=1)
x = nt.tensor(np.random.default_rng(0).normal(size=(8, 3, 64, 64)))   # батч из 8 цветных картинок 64×64
print(conv)
print("вход:", x.shape, "→ выход:", conv(x).shape)
print("параметров:", conv.num_params(), "=", 16, "× (3·3·3 + 1)")
print("а полносвязный слой с таким же входом и выходом:", 3 * 64 * 64 * 16 * 64 * 64, "весов")

448 параметров вместо 800 миллионов — и они работают для картинок любого размера.

Рецептивное поле

Нейрон первого свёрточного слоя с ядром 3 \times 3 видит область 3 \times 3 входа. Нейрон второго слоя видит 3 \times 3 нейронов первого, каждый из которых видит 3 \times 3 пикселей, — всего 5 \times 5. После L таких слоёв рецептивное поле — (2L + 1) \times (2L + 1). Шаг и пулинг (следующий урок) расширяют его гораздо быстрее. Так глубокие слои «видят» крупные объекты целиком, хотя каждое ядро маленькое.

Свёртка как матричное умножение

Циклы по позициям медленные. На практике все окна k \times k вытягивают в столбцы большой матрицы (im2col), и свёртка превращается в одно матричное умножение — ту самую операцию, которую видеокарты выполняют быстрее всего. Так устроена и conv2d в nanotorch:

import inspect
import nanotorch as nt

print(inspect.getsource(nt.conv2d))

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

Фильтры можно обучить

Главная идея свёрточных сетей: ядра не нужно придумывать — их можно выучить градиентным спуском. Проверим: возьмём свёрточный слой со случайным ядром 3 \times 3 и будем учить его выдавать то же, что фильтр Собеля:

import numpy as np
import nanotorch as nt

yy, xx = np.mgrid[0:32, 0:32]
img = np.zeros((32, 32))
img[6:16, 4:14] = 1.0
img[(yy - 21) ** 2 + (xx - 22) ** 2 < 49] = 0.7
img[np.abs(yy - (31 - xx)) < 1.5] = 0.4

sobel = np.array([[-1, 0, 1], [-2, 0, 2], [-1, 0, 1]], float)
rng = np.random.default_rng(0)
imgs = np.stack([np.roll(img, (rng.integers(32), rng.integers(32)), (0, 1)) for _ in range(8)])[:, None]
targets = nt.conv2d(nt.tensor(imgs), nt.tensor(sobel[None, None])).data     # что должен выдать слой

nt.manual_seed(0)
conv = nt.Conv2d(1, 1, kernel_size=3)
print("случайное ядро:\n", conv.weight.data[0, 0].round(2))
opt = nt.Adam(conv.parameters(), lr=0.1)
for step in range(300):
    loss = ((conv(nt.tensor(imgs)) - nt.tensor(targets)) ** 2).mean()
    opt.zero_grad()
    loss.backward()
    opt.step()
print(f"потери после обучения: {loss.data:.1e}")
print("выученное ядро:\n", conv.weight.data[0, 0].round(2) + 0.0)

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

Итог

  • Свёрточный слой: маленькое ядро скользит по входу, в каждой позиции — скалярное произведение окна и ядра.
  • Два свойства: локальность (выход зависит от небольшой области) и общие веса (одно ядро во всех позициях). Отсюда мало параметров и одинаковая реакция на узор в любом месте.
  • Размер выхода: \lfloor (n + 2p - k)/s \rfloor + 1; отступ сохраняет размер, шаг уменьшает.
  • Слой с F ядрами (C_{\text{in}}, k, k) превращает C_{\text{in}} каналов в F; параметров F(C_{\text{in}}k^2 + 1).
  • Ядра обучаются градиентным спуском; на практике свёртку считают через матричное умножение (im2col).

Практика решено 0 из 7

Вы не вошли — ответы проверяются, но прогресс не сохранится. Войти или зарегистрироваться.
Задание 1 Ввод ответа

Картинка 28 \times 28, ядро 5 \times 5, без отступа, шаг 1. Какого размера выход? (одно число — сторона квадрата)

Задание 2 Ввод ответа

Картинка 32 \times 32, ядро 3 \times 3, отступ 1, шаг 2. Какого размера выход? (сторона квадрата)

Задание 3 Ввод ответа

Сколько обучаемых параметров у слоя Conv2d(3, 16, kernel_size=3) (с bias)? Вход — цветная картинка с 3 каналами.

Задание 4 Один вариант ответа

Чем свёрточный слой принципиально отличается от полносвязного?

Задание 5 Заполнить таблицу

Что делает каждое ядро 3 \times 3?

размываетвыделяет вертикальные границыничего не меняет
все элементы 1/9
\begin{smallmatrix}-1&0&1\\-2&0&2\\-1&0&1\end{smallmatrix}
центр 1, остальные 0
Отметьте ячейку — «+», пустая — «−».
Задание 6 Программа на Python

Свёртка своими руками. Напишите conv2d_single(img, kernel) для одного канала без отступа и с шагом 1 (как в нейросетях — без переворота ядра):

\text{out}[i, j] = \sum_{a=0}^{k-1}\sum_{b=0}^{k-1} \text{img}[i + a,\, j + b] \cdot \text{kernel}[a, b]

Размер выхода — (H - k + 1) \times (W - k + 1). Ядро квадратное.

Какие тесты проверяют код (2)
img = np.arange(16.0).reshape(4, 4)
out = conv2d_single(img, np.ones((2, 2)))
assert out.shape == (3, 3), f"Размер выхода должен быть (3, 3), а не {out.shape}"
assert np.allclose(out, [[10, 14, 18], [26, 30, 34], [42, 46, 50]])

img = np.random.default_rng(0).normal(size=(6, 7))
k = np.array([[1.0, 2, 0], [0, -1, 0], [3, 0, 0]])
out = conv2d_single(img, k)
ref = np.array([[(img[i:i + 3, j:j + 3] * k).sum() for j in range(5)] for i in range(4)])
assert out.shape == (4, 5) and np.allclose(out, ref), "Ядро не переворачивается: out[i, j] = sum(img[i:i+k, j:j+k] * kernel)"

Задание 7 Программа на Python

Размер выхода. Напишите conv_out_size(n, k, padding=0, stride=1) — сторону выхода свёртки по формуле \left\lfloor \dfrac{n + 2p - k}{s} \right\rfloor + 1.

Какие тесты проверяют код (1)
assert conv_out_size(28, 5) == 24
assert conv_out_size(32, 3, padding=1) == 32, "Отступ 1 при ядре 3 сохраняет размер"
assert conv_out_size(32, 3, padding=1, stride=2) == 16
assert conv_out_size(224, 7, padding=3, stride=2) == 112, "Первый слой ResNet: 224 → 112"

Войдите, чтобы отмечать пройденные уроки.