Перейти к содержанию
Educora
Университет25 мин41 / 42

Свёрточные нейронные сети (CNN)

Изображения как тензоры (C × H × W), свёртка и формула размера выхода, фильтры, пулинг, небольшая CNN для изображений 28 × 28 с пошаговым разбором форм, MNIST в torchvision и перенос обучения.

Проверь себя
В этом уроке ты узнаешь
  • Представлять изображение тензором (N, C, H, W) и нормализовать его
  • Вычислять свёртку вручную, находить размер выхода и число параметров
  • Строить небольшую CNN для MNIST и прослеживать формы слой за слоем
  • Объяснять идею переноса обучения и адаптировать предобученную модель

Камера телефона распознаёт лица, программы-помощники врачей находят изменения на рентгеновских снимках, автомобили читают дорожные знаки. За большинством этих систем стоят свёрточные нейронные сети (CNN). В прошлом уроке мы видели, что полносвязный слой для цветного изображения 224 × 224 требует более 150 миллионов параметров. CNN опираются на два простых наблюдения: осмысленные узоры на изображении локальны (края, углы, текстуры), и один и тот же узор может появиться в любом месте снимка. Поэтому маленький фильтр «скользит» по всему изображению, а его веса общие для всех позиций.

Изображения как тензоры

Серое изображение 28 × 28 — это тензор формы (1, 28, 28): один канал, 28 строк, 28 столбцов. У цветного изображения три канала — красный, зелёный, синий: (3, H, W). Для пакетов PyTorch использует порядок осей (N, C, H, W). Пиксели обычно — целые числа 0–255; transforms.ToTensor() переводит их в интервал [0; 1], а затем каждый канал нормализуется.

x′ = (x − μ) / σx′ = (x − μ) / σ
где:
  • xзначение пикселя в [0; 1]
  • μ, σсреднее и стандартное отклонение канала по обучающей выборке (для MNIST 0,1307 и 0,3081)

После нормализации входы примерно центрированы около нуля и имеют единичный масштаб — это ускоряет градиентный спуск.

Операция свёртки

Фильтр (ядро, kernel) — маленькая матрица весов, например 3 × 3. Он скользит по изображению, и в каждой позиции поэлементно умножается на пиксели под ним, а произведения складываются. Получается карта признаков (feature map): в ней большие значения там, где есть узор, который «ищет» фильтр. В свёрточном слое несколько фильтров, и каждый создаёт свой выходной канал; каждый фильтр охватывает все входные каналы.

Y[i, j] = ∑ₘ ∑ₙ X[i + m, j + n] · K[m, n] + b
где:
  • Xвходное изображение (или карта предыдущего слоя)
  • Kфильтр K × K — обучаемые веса
  • bсмещение фильтра
  • Y[i, j]элемент (i, j) карты признаков

Двумерная свёртка для одного канала. При нескольких входных каналах сумма берётся и по каналам.

Код ниже вычисляет свёртку с нуля на NumPy. Левая половина изображения тёмная (0), правая — светлая (9); фильтр реагирует на рост яркости слева направо. Запусти код и проверь результат сам:

Python
import numpy as np

image = np.array([
    [0, 0, 0, 9, 9, 9],
    [0, 0, 0, 9, 9, 9],
    [0, 0, 0, 9, 9, 9],
    [0, 0, 0, 9, 9, 9],
    [0, 0, 0, 9, 9, 9],
])
kernel = np.array([
    [-1, 0, 1],
    [-1, 0, 1],
    [-1, 0, 1],
])
K = kernel.shape[0]
H, W = image.shape
out = np.zeros((H - K + 1, W - K + 1), dtype=int)
for i in range(out.shape[0]):
    for j in range(out.shape[1]):
        out[i, j] = np.sum(image[i:i + K, j:j + K] * kernel)
print(out)
▸ Ожидаемый результат
[[ 0 27 27  0]
 [ 0 27 27  0]
 [ 0 27 27  0]]
Значения 27 стоят ровно там, где проходит вертикальный край: фильтр — детектор краёв. В CNN такие фильтры не пишут вручную — их выучивает градиентный спуск.

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

O = ⌊(W − K + 2P) / S⌋ + 1O = ⌊(W − K + 2P) / S⌋ + 1
где:
  • Wширина (или высота) входа в пикселях
  • Kразмер ядра
  • Pдополнение (padding): нулевые пиксели по краям
  • Sшаг (stride): на сколько пикселей сдвигается фильтр
  • Oширина выхода; ⌊ ⌋ — округление вниз

Формула размера выхода; для высоты — то же самое. Для пулинга обычно P = 0 и S = K.

params = (K · K · Cin + 1) · Cout
где:
  • Cin, Coutчисло входных и выходных каналов

Число параметров свёрточного слоя не зависит от размера изображения — веса общие для всех позиций.

Пример 1: вычисли размеры выхода

Для входа 28 × 28 найди размер выхода: а) K = 3, P = 1, S = 1; б) K = 5, P = 0, S = 1; в) K = 3, P = 1, S = 2. г) Сколько параметров в слое Conv2d(1, 16, 3) с 16 фильтрами?

Показать решение
а) (28 − 3 + 2) / 1 + 1 = 28 — размер сохраняется.
б) (28 − 5 + 0) / 1 + 1 = 24.
в) ⌊(28 − 3 + 2) / 2⌋ + 1 = ⌊13,5⌋ + 1 = 14 — размер уменьшается вдвое.
г) (3 · 3 · 1 + 1) · 16 = 160. Для сравнения: у полносвязного слоя от изображения 28 × 28 к выходу 16 × 28 × 28 было бы 784 · 12 544 ≈ 9,8 миллиона весов!
Python
import torch
from torch import nn

torch.manual_seed(42)
x = torch.randn(8, 1, 28, 28)
conv = nn.Conv2d(in_channels=1, out_channels=16, kernel_size=3, padding=1)
print(conv(x).shape)
print(nn.Conv2d(1, 16, kernel_size=5)(x).shape)
print(nn.Conv2d(1, 16, kernel_size=3, stride=2, padding=1)(x).shape)
print(nn.MaxPool2d(kernel_size=2)(conv(x)).shape)
print(conv.weight.shape, sum(p.numel() for p in conv.parameters()))
Ожидаемый результат
torch.Size([8, 16, 28, 28])
torch.Size([8, 16, 24, 24])
torch.Size([8, 16, 14, 14])
torch.Size([8, 16, 14, 14])
torch.Size([16, 1, 3, 3]) 160
PyTorch подтверждает ответы примера 1. Веса фильтров имеют форму (Cout, Cin, K, K).

Пулинг и CNN для изображений 28 × 28

Max pooling оставляет из каждого окна (обычно 2 × 2) только наибольшее значение. Он вдвое уменьшает размер карты, удешевляет вычисления, добавляет устойчивость к небольшим сдвигам и вообще не имеет параметров. Average pooling берёт среднее.

Пример 2: пулинг вручную

Примени 2 × 2 max и average pooling (S = 2) к карте 4 × 4:
1 3 2 1
4 6 5 0
7 2 1 9
3 8 4 2

Показать решение
Окна: левое верхнее {1, 3, 4, 6}, правое верхнее {2, 1, 5, 0}, левое нижнее {7, 2, 3, 8}, правое нижнее {1, 9, 4, 2}.
Max: [[6, 5], [8, 9]].
Average: [[3,5; 2], [5; 4]].
Выход 2 × 2: O = ⌊(4 − 2) / 2⌋ + 1 = 2.

Теперь соберём небольшую CNN для MNIST: два блока «свёртка → ReLU → max pooling» извлекают признаки, а в конце Flatten и полносвязный слой выдают логиты для 10 цифр. Печатаем форму после каждого слоя — это самый надёжный способ отладки CNN.

Python
import torch
from torch import nn

class SmallCNN(nn.Module):
    def __init__(self):
        super().__init__()
        self.features = nn.Sequential(
            nn.Conv2d(1, 16, 3, padding=1), nn.ReLU(), nn.MaxPool2d(2),
            nn.Conv2d(16, 32, 3, padding=1), nn.ReLU(), nn.MaxPool2d(2),
        )
        self.classifier = nn.Sequential(nn.Flatten(), nn.Linear(32 * 7 * 7, 10))

    def forward(self, x):
        return self.classifier(self.features(x))

torch.manual_seed(42)
model = SmallCNN()
x = torch.randn(64, 1, 28, 28)
for layer in model.features:
    x = layer(x)
    print(f'{type(layer).__name__:9s} {tuple(x.shape)}')
print(model(torch.randn(64, 1, 28, 28)).shape)
print(sum(p.numel() for p in model.parameters()))
Ожидаемый результат
Conv2d    (64, 16, 28, 28)
ReLU      (64, 16, 28, 28)
MaxPool2d (64, 16, 14, 14)
Conv2d    (64, 32, 14, 14)
ReLU      (64, 32, 14, 14)
MaxPool2d (64, 32, 7, 7)
torch.Size([64, 10])
20490
Пример 3: формы и параметры

Проследи формы SmallCNN и посчитай её параметры вручную. Сравни с MLP из 4-го урока (101 770 параметров).

Показать решение
Формы: (1, 28, 28) → свёртка (P = 1): (16, 28, 28) → пулинг: (16, 14, 14) → свёртка: (32, 14, 14) → пулинг: (32, 7, 7) → flatten: 32 · 7 · 7 = 1568.
conv1: (3 · 3 · 1 + 1) · 16 = 160.
conv2: (3 · 3 · 16 + 1) · 32 = 4640.
fc: (1568 + 1) · 10 = 15 690.
Итого: 20 490 — примерно в 5 раз меньше, чем у MLP, но на MNIST обычно точнее, потому что сеть использует структуру изображений.

MNIST в torchvision

Пакет torchvision (pip install torchvision) даёт готовые наборы данных (MNIST, CIFAR-10, формат ImageNet), преобразования изображений (transforms) и предобученные модели. MNIST состоит из 70 000 изображений рукописных цифр: 60 000 обучающих и 10 000 тестовых, каждое — серое 28 × 28. download=True при первом запуске скачивает файлы в папку data, а потом читает их с диска.

Python
import torch
from torch.utils.data import DataLoader
from torchvision import datasets, transforms

torch.manual_seed(42)
transform = transforms.Compose([
    transforms.ToTensor(),
    transforms.Normalize((0.1307,), (0.3081,)),
])
train_set = datasets.MNIST('data', train=True, download=True, transform=transform)
test_set = datasets.MNIST('data', train=False, download=True, transform=transform)
train_loader = DataLoader(train_set, batch_size=64, shuffle=True)
test_loader = DataLoader(test_set, batch_size=1000)
images, labels = next(iter(train_loader))
print(len(train_set), len(test_set))
print(images.shape, labels.shape)
Ожидаемый результат
60000 10000
torch.Size([64, 1, 28, 28]) torch.Size([64])
Нужно подключение к интернету (только в первый раз). Цикл обучения тот же, что в прошлом уроке, — просто возьми model = SmallCNN().to(device). Такая небольшая CNN за несколько эпох обычно достигает на тесте точности около 98–99 %.

Перенос обучения (transfer learning)

Чтобы обучить большую CNN с нуля, нужны миллионы изображений и мощные GPU. Но края, текстуры и формы, которые выучили первые слои сети, обученной на ImageNet (1000 классов, более миллиона обучающих изображений), полезны почти для любой задачи с изображениями. Перенос обучения работает так: берём предобученную модель, замораживаем её слои, заменяем последний классифицирующий слой новым под свои классы и обучаем только его.

Python
import torch
from torch import nn
from torchvision import models

model = models.resnet18(weights=models.ResNet18_Weights.DEFAULT)
for p in model.parameters():
    p.requires_grad = False
print(model.fc)
model.fc = nn.Linear(model.fc.in_features, 3)
trainable = sum(p.numel() for p in model.parameters() if p.requires_grad)
total = sum(p.numel() for p in model.parameters())
print(trainable, total)
Ожидаемый результат
Linear(in_features=512, out_features=1000, bias=True)
1539 11178051
При первом запуске скачиваются веса ImageNet (около 45 МБ). Для задачи на 3 класса (например, три болезни листьев растения) обучаются только 1539 из 11,18 миллиона параметров.

В новом слое (512 + 1) · 3 = 1539 параметров. Столь малое число параметров быстро обучается на нескольких сотнях изображений, даже на CPU. Если данных больше, на втором этапе можно «разморозить» и несколько последних слоёв и дообучить (fine-tuning) всю сеть с очень маленькой скоростью обучения. Передавай оптимизатору только обучаемые параметры: torch.optim.Adam(model.fc.parameters(), lr=1e-3).

Главное

  • Изображения в PyTorch — тензоры (N, C, H, W); входы нормализуют по формуле (x − μ) / σ.
  • Свёртка сдвигает маленький фильтр по изображению; общие веса делают параметров мало: (K·K·Cin + 1)·Cout.
  • Размер выхода: O = ⌊(W − K + 2P) / S⌋ + 1; фильтр 3 × 3 с P = 1 сохраняет размер, S = 2 или пулинг 2 × 2 уменьшают его вдвое.
  • Типичная CNN: [Conv → ReLU → Pool] × n → Flatten → Linear; печатай формы после каждого слоя.
  • Перенос обучения: заморозь предобученную модель, замени последний слой и обучай только его.

Проверь себя

Вопросов: 10. Каждый правильный ответ приносит XP.

1 / 10
Вход 32 × 32, K = 5, P = 0, S = 1. Каков размер выхода?