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

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

Пулинг

Свёрточный слой сохраняет размер картинки (с отступом). Чтобы постепенно переходить от пикселей к крупным объектам, карты признаков уменьшают. Самый частый способ — max-пулинг: делим карту на окна 2 \times 2 и из каждого берём максимум.

┌───┬───┬───┬───┐ │ 1 │ 2 │ 0 │ 1 │ ├───┼───┼───┼───┤ ┌───┬───┐ │ 3 │ 4 │ 5 │ 0 │ max │ 4 │ 5 │ ├───┼───┼───┼───┤ ─────► ├───┼───┤ │ 0 │ 1 │ 2 │ 2 │ 2×2 │ 9 │ 3 │ ├───┼───┼───┼───┤ └───┴───┘ │ 9 │ 0 │ 1 │ 3 │ └───┴───┴───┴───┘

Что это даёт:

  • Меньше вычислений дальше по сети — в 4 раза меньше чисел.
  • Шире рецептивное поле — каждый следующий нейрон видит вдвое большую область по каждой стороне.
  • Устойчивость к небольшим сдвигам. Максимум в окне не меняется, если признак сдвинулся на пиксель внутри окна. Важно, что признак есть «где-то здесь», а не его точная позиция.

У пулинга нет параметров. Обратный проход простой: градиент идёт только в тот элемент окна, который был максимальным. Бывает и средний пулинг (average pooling), а глобальный пулинг сворачивает всю карту признаков в одно число на канал — так делают перед последним слоем многие современные сети.

Архитектура свёрточной сети

Классическая схема — несколько блоков «свёртка → ReLU → пулинг», затем полносвязный классификатор:

картинка ──► [Conv → ReLU → Pool] ──► [Conv → ReLU → Pool] ──► … ──► Flatten ──► Linear ──► классы 1×8×8 8×8×8 → 8×4×4 16×4×4 → 16×2×2 64 10 границы, пятна углы, дуги, штрихи

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

Свёрточная сеть для цифр

Соберём маленькую свёрточную сеть для наших цифр 8 \times 8. Картинки нужно подать в форме (N, C, H, W) — с одним каналом:

import numpy as np
import nanotorch as nt
import ml_data
import vis

X, y = ml_data.load_digits()
X = (X / 16.0).reshape(-1, 1, 8, 8)                 # (N, 1, 8, 8)
X_train, X_test, y_train, y_test = nt.train_test_split(X, y, test_size=0.25, seed=0)

nt.manual_seed(0)
cnn = nt.Sequential(
    nt.Conv2d(1, 8, kernel_size=3, padding=1), nt.ReLU(), nt.MaxPool2d(2),     # 8×8×8 → 8×4×4
    nt.Conv2d(8, 16, kernel_size=3, padding=1), nt.ReLU(), nt.MaxPool2d(2),    # 16×4×4 → 16×2×2
    nt.Flatten(), nt.Linear(64, 10),
)
print(cnn)
print("параметров:", cnn.num_params())
h = nt.fit(cnn, X_train, y_train, nt.cross_entropy, nt.Adam(cnn.parameters(), lr=0.003),
           epochs=15, batch_size=32, X_val=X_test, y_val=y_test, metric=nt.accuracy, verbose=False)
print(f"точность на тесте: {h['val_metric'][-1]:.1%}")

# фильтры первого слоя и что они находят на одной картинке
cnn.eval()
vis.images(cnn.layers[0].weight.data[:, 0], titles=[f"фильтр {i}" for i in range(8)], cols=8)
maps = cnn.layers[1](cnn.layers[0](nt.tensor(X_test[:1]))).data[0]           # после Conv + ReLU
vis.images([X_test[0, 0]] + list(maps), titles=[f"цифра {y_test[0]}"] + [f"карта {i}" for i in range(8)], cols=9)

Около 97% при всего ~1900 параметрах — меньше, чем у полносвязной сети из проекта, у которой было почти 10 000. Первая строка картинок — выученные ядра 3 \times 3 (красные веса положительные, синие — отрицательные). Вторая — что каждое ядро «видит» на цифре: разные карты подсвечивают разные штрихи и края.

Эксперимент 1: узор в новом месте

Вот где проявляется сила общих весов. Сделаем картинки 12 \times 12 с одним маленьким узором: «плюс» или «крестик». Задача — сказать, какой. В обучающих картинках узор всегда стоит в левой части, а проверим на картинках, где он в правой — там сеть его ни разу не видела.

import numpy as np
import nanotorch as nt
import vis

PATTERNS = {0: np.array([[0, 1, 0], [1, 1, 1], [0, 1, 0]], float),     # плюс
            1: np.array([[1, 0, 1], [0, 1, 0], [1, 0, 1]], float)}     # крестик

def make(n, side, rng):
    X, y = np.zeros((n, 1, 12, 12)), rng.integers(0, 2, n)
    for i in range(n):
        r = rng.integers(0, 10)
        c = rng.integers(0, 4) if side == "left" else rng.integers(6, 10)
        X[i, 0, r:r + 3, c:c + 3] = PATTERNS[y[i]]
    return X + rng.normal(0, 0.1, X.shape), y

rng = np.random.default_rng(0)
X_train, y_train = make(400, "left", rng)
X_left, y_left = make(300, "left", rng)
X_right, y_right = make(300, "right", rng)
vis.images(list(X_train[:4, 0]) + list(X_right[:4, 0]), titles=["обучение"] * 4 + ["проверка"] * 4, cols=8)

makers = {
    "полносвязная": lambda: nt.Sequential(nt.Flatten(), nt.Linear(144, 64), nt.ReLU(), nt.Linear(64, 2)),
    "свёрточная": lambda: nt.Sequential(nt.Conv2d(1, 8, 3, padding=1), nt.ReLU(),
                                        nt.MaxPool2d(12),           # глобальный max-пулинг: максимум по всей карте
                                        nt.Flatten(), nt.Linear(8, 2)),
}
for name, make_model in makers.items():
    nt.manual_seed(0)
    model = make_model()
    nt.fit(model, X_train, y_train, nt.cross_entropy, nt.Adam(model.parameters(), lr=0.01),
           epochs=30, batch_size=32, verbose=False)
    model.eval()
    acc = lambda X, y: nt.accuracy(model(nt.tensor(X)), y)
    print(f"{name:13} ({model.num_params():5} параметров): узор слева {acc(X_left, y_left):.0%}, узор справа {acc(X_right, y_right):.0%}")

Полносвязная сеть выучила узоры только там, где их видела: на правой половине она угадывает не лучше монетки. У её весов для правой половины просто не было обучающих примеров. Свёрточная сеть со своими 98 параметрами справляется везде: её ядро ищет узор во всех позициях, а глобальный пулинг отвечает на вопрос «есть ли узор хоть где-нибудь». Это свойство называют эквивариантностью к сдвигу (свёртка: сдвинули вход — сдвинулся выход) и инвариантностью (после глобального пулинга: сдвинули вход — ответ не изменился).

Эксперимент 2: перемешанные пиксели

Перемешаем пиксели во всех картинках цифр одним и тем же случайным образом. Человек такие картинки не узнает. А сеть?

import numpy as np
import nanotorch as nt
import ml_data
import vis

X, y = ml_data.load_digits()
X = X / 16.0
X_train, X_test, y_train, y_test = nt.train_test_split(X, y, test_size=0.25, seed=0)
perm = np.random.default_rng(0).permutation(64)
vis.images([X_train[0].reshape(8, 8), X_train[0, perm].reshape(8, 8)], titles=["как есть", "перемешано"])

def make(name):
    nt.manual_seed(0)
    if name == "полносвязная":
        return nt.Sequential(nt.Flatten(), nt.Linear(64, 64), nt.ReLU(), nt.Linear(64, 10))
    return nt.Sequential(nt.Conv2d(1, 8, 3, padding=1), nt.ReLU(), nt.MaxPool2d(2),
                         nt.Conv2d(8, 16, 3, padding=1), nt.ReLU(), nt.MaxPool2d(2), nt.Flatten(), nt.Linear(64, 10))

for name in ("полносвязная", "свёрточная"):
    for shuffled in (False, True):
        A, B = (X_train[:, perm], X_test[:, perm]) if shuffled else (X_train, X_test)
        model = make(name)
        nt.fit(model, A.reshape(-1, 1, 8, 8), y_train, nt.cross_entropy, nt.Adam(model.parameters(), lr=0.003),
               epochs=15, batch_size=32, verbose=False)
        model.eval()
        acc = nt.accuracy(model(nt.tensor(B.reshape(-1, 1, 8, 8))), y_test)
        print(f"{name:13} {'перемешано' if shuffled else 'как есть  '}: {acc:.1%}")

Полносвязной сети всё равно: для неё картинка — набор из 64 чисел, порядок которых ничего не значит, и качество почти не изменилось. Свёрточная сеть на перемешанных пикселях заметно хуже — её ядра ищут соседние пиксели, а соседство разрушено. Это не слабость, а признак: свёрточная сеть использует знание о том, как устроены картинки. Чем больше картинка, тем важнее это знание.

Знаменитые архитектуры

  • LeNet-5 (Ян Лекун, 1998) — распознавание рукописных цифр на чеках: две свёртки с пулингом и полносвязные слои. По сути, наша сеть выше.
  • AlexNet (2012) — 8 слоёв, ReLU, Dropout, обучение на видеокартах. Выиграла конкурс ImageNet (1000 классов, 1.2 млн фотографий) с огромным отрывом и запустила бум глубокого обучения.
  • VGG (2014) — простая идея: только свёртки 3 \times 3, но много слоёв (16–19). Два слоя 3 \times 3 видят как один 5 \times 5, но с меньшим числом параметров и лишней нелинейностью.
  • ResNet (2015) — остаточные связи y = x + F(x): блок учит не всё преобразование, а поправку к входу. Градиент течёт по «прямой дороге» без затухания, и стало возможно обучать сети из 50–150 слоёв. Остаточные связи теперь есть почти везде — включая трансформеры.
x ──────────────────────┐ │ │ (прямая дорога) ▼ │ Conv → BN → ReLU → Conv → BN │ │ │ ▼ ▼ └───────────► + ◄───────┘ ──► ReLU ──► y = ReLU(x + F(x))

Также в современных свёрточных сетях почти всегда есть BatchNorm после свёрток и глобальный средний пулинг вместо больших полносвязных слоёв.

Перенос обучения

Обучить большую свёрточную сеть с нуля — это миллионы картинок и дни работы видеокарт. Но первые слои любой сети, обученной на фотографиях, находят примерно одно и то же: границы, текстуры, простые формы. Поэтому на практике берут сеть, предобученную на ImageNet, заменяют последний слой под свои классы и дообучают — иногда только этот слой, иногда всю сеть с маленьким шагом. Так хорошие модели получаются даже из сотен размеченных картинок.

Итог

  • Пулинг уменьшает карты признаков, расширяет рецептивное поле и даёт устойчивость к небольшим сдвигам; параметров у него нет.
  • Свёрточная сеть: блоки «свёртка → ReLU → пулинг», затем полносвязный классификатор; каналов становится больше, карты — меньше.
  • Общие веса позволяют распознавать узор там, где его не было при обучении, и требуют гораздо меньше параметров.
  • Свёрточная сеть использует соседство пикселей — это её главное преимущество перед полносвязной.
  • Остаточные связи (ResNet) позволяют обучать очень глубокие сети; перенос обучения — делать хорошие модели на малых данных.

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

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

Карта признаков 16 \times 16 с 32 каналами проходит через max-пулинг 2 \times 2. Сколько чисел на выходе?

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

Сколько обучаемых параметров у слоя max-пулинга 2 \times 2?

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

Зачем в ResNet остаточные связи y = x + F(x)?

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

Что верно для свёрточной сети, а что — для полносвязной?

свёрточнаяполносвязнаяобе
Учитывает, какие пиксели — соседи
Число весов первого слоя не зависит от размера картинки
Обучается обратным распространением ошибки
Если одинаково перемешать пиксели во всех картинках, качество почти не изменится
Узор, выученный в одном месте, распознаётся в другом
Отметьте ячейку — «+», пустая — «−».
Задание 5 Один вариант ответа

У вас 500 размеченных снимков рентгена. Как лучше поступить?

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

Max-пулинг своими руками. Напишите max_pool(x, k) для одного канала: разбейте картинку x формы (H, W) на непересекающиеся окна k \times k и возьмите максимум в каждом. Если H или W не делится на k, лишние строки и столбцы снизу и справа отбросьте. Результат — массив формы (H // k, W // k).

Какие тесты проверяют код (2)
x = np.array([[1, 2, 0, 1],
              [3, 4, 5, 0],
              [0, 1, 2, 2],
              [9, 0, 1, 3]], float)
assert np.array_equal(max_pool(x, 2), [[4, 5], [9, 3]]), "Максимум в каждом окне 2×2"

x = np.arange(35.0).reshape(5, 7)
out = max_pool(x, 2)
assert out.shape == (2, 3), "Лишние строки и столбцы отбрасываются: (5, 7) → (2, 3)"
assert np.array_equal(out, [[8, 10, 12], [22, 24, 26]])
assert np.array_equal(max_pool(x, 1), x), "Пулинг 1×1 ничего не меняет"

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