Свёрточные слои
Почему не полносвязная сеть
В прошлом модуле мы распознавали цифры 8 \times 8 полносвязной сетью, и она справилась. Но с настоящими картинками у неё две беды:
- Слишком много параметров. Цветная картинка 224 \times 224 — это 224 \cdot 224 \cdot 3 \approx 150\,000 чисел. Один полносвязный слой на 1000 нейронов — 150 миллионов весов. Такую сеть трудно обучить, и она легко переобучится.
- Нет понятия «где». Для полносвязного слоя вход — просто список чисел. Кошка в левом углу и кошка в правом — совершенно разные входы, и сеть должна выучить кошку отдельно для каждого места. Она даже не знает, какие пиксели — соседи.
А мы знаем о картинках две вещи. Важные узоры локальны: край, угол, глаз занимают небольшую область. И узор значит одно и то же в любом месте: край есть край, где бы он ни был. Свёртка встраивает оба знания прямо в архитектуру.
Свёртка на одномерном сигнале
Начнём с одномерного случая. Ядро (фильтр) — короткий массив весов. Скользим им по сигналу и в каждой позиции считаем скалярное произведение ядра и кусочка сигнала:
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), который скользит по изображению слева направо и сверху вниз:
Строго говоря, это взаимная корреляция: в математической свёртке ядро сначала переворачивают. В нейросетях переворот не делают — ядро всё равно обучается, и ему безразлично, в каком порядке хранить веса. Но название «свёртка» прижилось.
Классические фильтры
До нейросетей ядра для обработки изображений подбирали вручную. Попробуем несколько на простой картинке:
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 уменьшает картинку вдвое по каждой стороне. Общая формула:
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 каналов:
Следующий слой получает 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
Картинка 28 \times 28, ядро 5 \times 5, без отступа, шаг 1. Какого размера выход? (одно число — сторона квадрата)
Картинка 32 \times 32, ядро 3 \times 3, отступ 1, шаг 2. Какого размера выход? (сторона квадрата)
Сколько обучаемых параметров у слоя Conv2d(3, 16, kernel_size=3) (с bias)? Вход — цветная картинка с 3 каналами.
Чем свёрточный слой принципиально отличается от полносвязного?
Что делает каждое ядро 3 \times 3?
| размывает | выделяет вертикальные границы | ничего не меняет | |
|---|---|---|---|
| все элементы 1/9 | |||
| \begin{smallmatrix}-1&0&1\\-2&0&2\\-1&0&1\end{smallmatrix} | |||
| центр 1, остальные 0 |
Свёртка своими руками. Напишите conv2d_single(img, kernel) для одного канала без отступа и с шагом 1
(как в нейросетях — без переворота ядра):
Размер выхода — (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)"
import numpy as np
def conv2d_single(img, kernel):
k = kernel.shape[0]
H, W = img.shape
out = np.zeros((H - k + 1, W - k + 1))
for i in range(H - k + 1):
for j in range(W - k + 1):
out[i, j] = (img[i:i + k, j:j + k] * kernel).sum()
return out
Размер выхода. Напишите 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"
def conv_out_size(n, k, padding=0, stride=1):
return (n + 2 * padding - k) // stride + 1
Войдите, чтобы отмечать пройденные уроки.